--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit eca1486f3c00aeb0b97d7199ccc03b929d9619f1
Parents : 96243d4
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-06T22:20:36-05:00
feat(plugin): add plugin system with management UI and backend support, including new transport node monitor plugin
Changes
45 files changed, 4478 insertions(+), 2287 deletions(-)
Diff
diff --git a/docs/meshchatx.md b/docs/meshchatx.md
index 95f8dfb9..0628cdf2 100644
--- a/docs/meshchatx.md
+++ b/docs/meshchatx.md
@@ -150,11 +150,19 @@ Authoring rules, security constraints for HTML/CSS, and API behaviour are docume
## Extensibility Points
+MeshChatX supports a capability-based plugin system with separate frontend and backend runtimes:
+
+- **Contribution registries** under `meshchatx/src/frontend/js/registries/` for sidebar navigation, tools, command palette actions, settings sections, and typed WebSocket events.
+- **Frontend plugins** run in dedicated Workers (`meshchatx/src/frontend/js/plugins/PluginHost.js`) with declarative UI slots rendered by `PluginSlotRenderer.vue`.
+- **Backend plugins** run in wasmtime with fuel metering and capability-gated host functions (`meshchatx/src/backend/plugin_manager.py`).
+- **Generic plugin API** under `/api/v1/plugins/*` for install, enable/disable, invoke, and asset serving.
+
The most practical extension points today are:
+- plugin manifests in `plugin.json` with `contributes` and `permissions` blocks,
- new API routes in backend routing sections,
- new manager modules under `meshchatx/src/backend`,
-- frontend page/component additions wired through existing router/state patterns,
+- frontend page/component additions wired through contribution registries,
- new config surface through CLI flags + environment variables,
- schema extension through the existing migration/versioning approach.
@@ -162,4 +170,5 @@ When adding features, prefer:
- identity-scoped state over global mutable state,
- explicit migration/version changes for DB schema updates,
-- endpoint-level tests plus focused manager unit tests.
+- endpoint-level tests plus focused manager unit tests,
+- plugin permissions that are declared in manifests and enforced by the host.
diff --git a/meshchatx/src/backend/data/plugins/transport-node-monitor/frontend/main.js b/meshchatx/src/backend/data/plugins/transport-node-monitor/frontend/main.js
new file mode 100644
index 00000000..657e625e
--- /dev/null
+++ b/meshchatx/src/backend/data/plugins/transport-node-monitor/frontend/main.js
@@ -0,0 +1,71 @@
+export async function activate(api) {
+ let watchedNodes = [];
+ let paths = [];
+
+ async function refresh() {
+ const state = await api.invoke("getState");
+ watchedNodes = state?.watched_nodes || [];
+ const pathResult = await api.invoke("readPaths", { destination_hash: null });
+ paths = pathResult?.paths || [];
+ api.setUi({
+ type: "column",
+ children: [
+ {
+ type: "text",
+ variant: "title",
+ value: api.t("plugins.transport_node_monitor.title"),
+ },
+ {
+ type: "text",
+ value: api.t("plugins.transport_node_monitor.description"),
+ },
+ {
+ type: "input",
+ id: "watch-hash",
+ label: api.t("plugins.transport_node_monitor.watch_hash"),
+ placeholder: api.t("plugins.transport_node_monitor.watch_hash_placeholder"),
+ },
+ {
+ type: "button",
+ id: "add-watch",
+ label: api.t("plugins.transport_node_monitor.add_watch"),
+ },
+ {
+ type: "list",
+ items: watchedNodes.map((hash) => ({
+ type: "row",
+ children: [
+ { type: "text", value: hash },
+ {
+ type: "text",
+ value:
+ paths.find((entry) => entry.destination_hash === hash)?.hops?.toString() ??
+ api.t("plugins.transport_node_monitor.no_path"),
+ },
+ ],
+ })),
+ },
+ ],
+ });
+ }
+
+ api.onAction(async (actionId) => {
+ if (actionId !== "add-watch") {
+ return;
+ }
+ const input = api.getInputValue("watch-hash");
+ const hash = (input || "").trim().toLowerCase();
+ if (!hash || watchedNodes.includes(hash)) {
+ return;
+ }
+ watchedNodes = [...watchedNodes, hash];
+ await api.invoke("setWatchedNodes", { nodes: watchedNodes });
+ await refresh();
+ });
+
+ api.onEvent("announce.received", async () => {
+ await refresh();
+ });
+
+ await refresh();
+}
diff --git a/meshchatx/src/backend/data/plugins/transport-node-monitor/plugin.json b/meshchatx/src/backend/data/plugins/transport-node-monitor/plugin.json
new file mode 100644
index 00000000..02561019
--- /dev/null
+++ b/meshchatx/src/backend/data/plugins/transport-node-monitor/plugin.json
@@ -0,0 +1,47 @@
+{
+ "id": "com.meshchatx.transport-node-monitor",
+ "version": "1.0.0",
+ "apiVersion": 1,
+ "name": "Transport Node Monitor",
+ "description": "Track watched transport nodes, path hops, and announce activity.",
+ "frontend": {
+ "entry": "frontend/main.js",
+ "type": "js"
+ },
+ "backend": {
+ "entry": "backend/plugin.wasm",
+ "type": "wasm"
+ },
+ "contributes": {
+ "navItems": [
+ {
+ "id": "transport-node-monitor",
+ "route": { "name": "plugin-transport-node-monitor" },
+ "icon": "router-wireless",
+ "labelKey": "plugins.transport_node_monitor.nav"
+ }
+ ],
+ "toolsPageEntries": [
+ {
+ "name": "transport-node-monitor",
+ "route": { "name": "plugin-transport-node-monitor" },
+ "icon": "router-wireless",
+ "iconBg": "tool-card__icon bg-sky-50 text-sky-600 dark:bg-sky-900/30 dark:text-sky-200",
+ "titleKey": "plugins.transport_node_monitor.title",
+ "descriptionKey": "plugins.transport_node_monitor.description"
+ }
+ ],
+ "settingsSections": [
+ {
+ "id": "plugins",
+ "tab": "maintenance"
+ }
+ ]
+ },
+ "permissions": {
+ "hooks": ["announce.received"],
+ "managers": ["destinationPath.read"],
+ "storage": "isolated",
+ "network": "none"
+ }
+}
diff --git a/meshchatx/src/backend/plugin_manager.py b/meshchatx/src/backend/plugin_manager.py
new file mode 100644
index 00000000..538ba855
--- /dev/null
+++ b/meshchatx/src/backend/plugin_manager.py
@@ -0,0 +1,534 @@
+# SPDX-License-Identifier: 0BSD
+
+from __future__ import annotations
+
+import json
+import os
+import re
+import shutil
+import sqlite3
+import threading
+import zipfile
+from dataclasses import dataclass, field
+from typing import Any, Callable, Optional
+
+SUPPORTED_API_VERSION = 1
+PLUGIN_ID_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$")
+
+MINIMAL_PLUGIN_WAT = """
+(module
+ (import "host" "log" (func (param i32 i32)))
+ (memory (export "memory") 1)
+ (func (export "on_hook") (param i32 i32) (result i32)
+ i32.const 0
+ )
+ (func (export "invoke") (param i32 i32 i32) (result i32)
+ i32.const 0
+ )
+)
+""".strip()
+
+
+@dataclass
+class PluginRecord:
+ id: str
+ version: str
+ manifest: dict[str, Any]
+ enabled: bool
+ install_path: str
+ auto_disabled_reason: str | None = None
+ announce_handlers: list[Any] = field(default_factory=list)
+
+
+class PluginManager:
+ """Discover, install, and execute MeshChatX plugins."""
+
+ def __init__(self, storage_dir: str, app: Any | None = None):
+ self.storage_dir = os.path.join(storage_dir, "plugins")
+ self.installed_dir = os.path.join(self.storage_dir, "installed")
+ self.state_db_path = os.path.join(self.storage_dir, "plugin_state.db")
+ self.app = app
+ self._lock = threading.RLock()
+ self._plugins: dict[str, PluginRecord] = {}
+ self._wasmtime = None
+ os.makedirs(self.installed_dir, exist_ok=True)
+ self._init_state_db()
+ self._load_installed_plugins()
+
+ def _init_state_db(self) -> None:
+ with sqlite3.connect(self.state_db_path) as conn:
+ conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS plugin_storage (
+ plugin_id TEXT NOT NULL,
+ storage_key TEXT NOT NULL,
+ storage_value TEXT NOT NULL,
+ PRIMARY KEY (plugin_id, storage_key)
+ )
+ """
+ )
+ conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS plugin_state (
+ plugin_id TEXT PRIMARY KEY,
+ enabled INTEGER NOT NULL DEFAULT 0,
+ auto_disabled_reason TEXT
+ )
+ """
+ )
+ conn.commit()
+
+ def set_app(self, app: Any) -> None:
+ self.app = app
+
+ def _load_wasmtime(self):
+ if self._wasmtime is not None:
+ return self._wasmtime
+ try:
+ import wasmtime
+ except ImportError as exc:
+ raise RuntimeError("wasmtime is required for backend plugins") from exc
+ self._wasmtime = wasmtime
+ return wasmtime
+
+ def _load_installed_plugins(self) -> None:
+ if not os.path.isdir(self.installed_dir):
+ return
+ for entry in sorted(os.listdir(self.installed_dir)):
+ plugin_dir = os.path.join(self.installed_dir, entry)
+ manifest_path = os.path.join(plugin_dir, "plugin.json")
+ if not os.path.isfile(manifest_path):
+ continue
+ try:
+ with open(manifest_path, encoding="utf-8") as handle:
+ manifest = json.load(handle)
+ manifest = self._validate_manifest(manifest)
+ enabled, auto_disabled_reason = self._read_plugin_state(manifest["id"])
+ self._plugins[manifest["id"]] = PluginRecord(
+ id=manifest["id"],
+ version=manifest["version"],
+ manifest=manifest,
+ enabled=enabled,
+ install_path=plugin_dir,
+ auto_disabled_reason=auto_disabled_reason,
+ )
+ except Exception as exc:
+ print(f"Failed to load plugin from {plugin_dir}: {exc}")
+
+ def _read_plugin_state(self, plugin_id: str) -> tuple[bool, str | None]:
+ with sqlite3.connect(self.state_db_path) as conn:
+ row = conn.execute(
+ "SELECT enabled, auto_disabled_reason FROM plugin_state WHERE plugin_id = ?",
+ (plugin_id,),
+ ).fetchone()
+ if not row:
+ return False, None
+ return bool(row[0]), row[1]
+
+ def _write_plugin_state(
+ self, plugin_id: str, enabled: bool, auto_disabled_reason: str | None = None
+ ) -> None:
+ with sqlite3.connect(self.state_db_path) as conn:
+ conn.execute(
+ """
+ INSERT INTO plugin_state (plugin_id, enabled, auto_disabled_reason)
+ VALUES (?, ?, ?)
+ ON CONFLICT(plugin_id) DO UPDATE SET
+ enabled = excluded.enabled,
+ auto_disabled_reason = excluded.auto_disabled_reason
+ """,
+ (plugin_id, 1 if enabled else 0, auto_disabled_reason),
+ )
+ conn.commit()
+
+ def _validate_manifest(self, manifest: dict[str, Any]) -> dict[str, Any]:
+ if not isinstance(manifest, dict):
+ raise ValueError("plugin manifest must be an object")
+ plugin_id = manifest.get("id")
+ if not isinstance(plugin_id, str) or not PLUGIN_ID_RE.match(plugin_id):
+ raise ValueError("plugin id is invalid")
+ version = manifest.get("version")
+ if not isinstance(version, str) or not version.strip():
+ raise ValueError("plugin version is required")
+ api_version = manifest.get("apiVersion")
+ if int(api_version) != SUPPORTED_API_VERSION:
+ raise ValueError(f"unsupported apiVersion (expected {SUPPORTED_API_VERSION})")
+ permissions = manifest.get("permissions") or {}
+ if permissions and not isinstance(permissions, dict):
+ raise ValueError("permissions must be an object")
+ return manifest
+
+ def list_plugins(self) -> list[dict[str, Any]]:
+ with self._lock:
+ rows = []
+ for record in self._plugins.values():
+ rows.append(self._public_plugin_view(record))
+ rows.sort(key=lambda item: item["id"])
+ return rows
+
+ def get_plugin(self, plugin_id: str) -> dict[str, Any] | None:
+ with self._lock:
+ record = self._plugins.get(plugin_id)
+ if not record:
+ return None
+ return self._public_plugin_view(record)
+
+ def _public_plugin_view(self, record: PluginRecord) -> dict[str, Any]:
+ manifest = record.manifest
+ permissions = manifest.get("permissions") or {}
+ return {
+ "id": record.id,
+ "version": record.version,
+ "name": manifest.get("name") or record.id,
+ "description": manifest.get("description") or "",
+ "enabled": record.enabled,
+ "auto_disabled_reason": record.auto_disabled_reason,
+ "manifest": manifest,
+ "permissions": permissions,
+ "contributes": manifest.get("contributes") or {},
+ "has_frontend": bool(manifest.get("frontend")),
+ "has_backend": bool(manifest.get("backend")),
+ }
+
+ def install_from_directory(self, source_dir: str) -> dict[str, Any]:
+ manifest_path = os.path.join(source_dir, "plugin.json")
+ if not os.path.isfile(manifest_path):
+ raise ValueError("plugin.json not found")
+ with open(manifest_path, encoding="utf-8") as handle:
+ manifest = self._validate_manifest(json.load(handle))
+ plugin_id = manifest["id"]
+ target_dir = os.path.join(self.installed_dir, plugin_id)
+ if os.path.exists(target_dir):
+ shutil.rmtree(target_dir)
+ shutil.copytree(source_dir, target_dir)
+ with self._lock:
+ enabled, auto_disabled_reason = self._read_plugin_state(plugin_id)
+ self._plugins[plugin_id] = PluginRecord(
+ id=plugin_id,
+ version=manifest["version"],
+ manifest=manifest,
+ enabled=enabled,
+ install_path=target_dir,
+ auto_disabled_reason=auto_disabled_reason,
+ )
+ return self._public_plugin_view(self._plugins[plugin_id])
+
+ def install_from_zip_bytes(self, payload: bytes) -> dict[str, Any]:
+ import tempfile
+
+ with tempfile.TemporaryDirectory() as tmp:
+ zip_path = os.path.join(tmp, "plugin.zip")
+ with open(zip_path, "wb") as handle:
+ handle.write(payload)
+ extract_dir = os.path.join(tmp, "extract")
+ os.makedirs(extract_dir, exist_ok=True)
+ with zipfile.ZipFile(zip_path) as archive:
+ archive.extractall(extract_dir)
+ plugin_root = extract_dir
+ if not os.path.isfile(os.path.join(plugin_root, "plugin.json")):
+ children = [
+ name
+ for name in os.listdir(extract_dir)
+ if os.path.isdir(os.path.join(extract_dir, name))
+ ]
+ if len(children) == 1:
+ plugin_root = os.path.join(extract_dir, children[0])
+ return self.install_from_directory(plugin_root)
+
+ def enable(self, plugin_id: str) -> dict[str, Any]:
+ with self._lock:
+ record = self._require_plugin(plugin_id)
+ record.enabled = True
+ record.auto_disabled_reason = None
+ self._write_plugin_state(plugin_id, True, None)
+ self._register_plugin_hooks(record)
+ return self._public_plugin_view(record)
+
+ def disable(self, plugin_id: str, reason: str | None = None) -> dict[str, Any]:
+ with self._lock:
+ record = self._require_plugin(plugin_id)
+ record.enabled = False
+ if reason:
+ record.auto_disabled_reason = reason
+ self._write_plugin_state(plugin_id, False, record.auto_disabled_reason)
+ self._unregister_plugin_hooks(record)
+ if reason:
+ self._broadcast_plugin_event(plugin_id, "plugin.disabled", {"reason": reason})
+ return self._public_plugin_view(record)
+
+ def remove(self, plugin_id: str) -> None:
+ with self._lock:
+ record = self._plugins.pop(plugin_id, None)
+ if record:
+ self._unregister_plugin_hooks(record)
+ target_dir = os.path.join(self.installed_dir, plugin_id)
+ if os.path.isdir(target_dir):
+ shutil.rmtree(target_dir)
+ with sqlite3.connect(self.state_db_path) as conn:
+ conn.execute("DELETE FROM plugin_state WHERE plugin_id = ?", (plugin_id,))
+ conn.execute("DELETE FROM plugin_storage WHERE plugin_id = ?", (plugin_id,))
+ conn.commit()
+
+ def asset_path(self, plugin_id: str, asset_name: str) -> str:
+ record = self._require_plugin(plugin_id)
+ normalized = os.path.normpath(asset_name).replace("\\", "/")
+ if normalized.startswith("..") or normalized.startswith("/"):
+ raise ValueError("invalid asset path")
+ path = os.path.join(record.install_path, normalized)
+ if not os.path.isfile(path):
+ raise FileNotFoundError(asset_name)
+ return path
+
+ def _require_plugin(self, plugin_id: str) -> PluginRecord:
+ record = self._plugins.get(plugin_id)
+ if not record:
+ raise KeyError(f"plugin not found: {plugin_id}")
+ return record
+
+ def _permission_allowed(self, record: PluginRecord, capability: str) -> bool:
+ permissions = record.manifest.get("permissions") or {}
+ managers = permissions.get("managers") or []
+ return capability in managers
+
+ def _hook_allowed(self, record: PluginRecord, hook: str) -> bool:
+ permissions = record.manifest.get("permissions") or {}
+ hooks = permissions.get("hooks") or []
+ return hook in hooks
+
+ def storage_get(self, plugin_id: str, key: str) -> str | None:
+ with sqlite3.connect(self.state_db_path) as conn:
+ row = conn.execute(
+ "SELECT storage_value FROM plugin_storage WHERE plugin_id = ? AND storage_key = ?",
+ (plugin_id, key),
+ ).fetchone()
+ return row[0] if row else None
+
+ def storage_set(self, plugin_id: str, key: str, value: str) -> None:
+ with sqlite3.connect(self.state_db_path) as conn:
+ conn.execute(
+ """
+ INSERT INTO plugin_storage (plugin_id, storage_key, storage_value)
+ VALUES (?, ?, ?)
+ ON CONFLICT(plugin_id, storage_key) DO UPDATE SET storage_value = excluded.storage_value
+ """,
+ (plugin_id, key, value),
+ )
+ conn.commit()
+
+ def call_manager(self, plugin_id: str, capability: str, args: dict[str, Any]) -> Any:
+ record = self._require_plugin(plugin_id)
+ if not record.enabled:
+ raise PermissionError("plugin is disabled")
+ if not self._permission_allowed(record, capability):
+ raise PermissionError(f"capability not granted: {capability}")
+ if capability == "destinationPath.read":
+ return self._destination_path_read(args)
+ raise ValueError(f"unknown capability: {capability}")
+
+ def _destination_path_read(self, args: dict[str, Any]) -> dict[str, Any]:
+ if not self.app or not getattr(self.app, "reticulum", None):
+ return {"paths": []}
+ destination_hash = args.get("destination_hash")
+ paths: list[dict[str, Any]] = []
+ reticulum = self.app.reticulum
+ if destination_hash:
+ hashes = [destination_hash]
+ else:
+ hashes = []
+ try:
+ table = reticulum.get_path_table()
+ hashes = [entry.get("destination_hash") for entry in table if entry.get("destination_hash")]
+ except Exception:
+ hashes = []
+ for item in hashes:
+ if not item:
+ continue
+ try:
+ raw = bytes.fromhex(item) if isinstance(item, str) else item
+ hops = reticulum.get_hops_to(raw) if hasattr(reticulum, "get_hops_to") else None
+ paths.append({"destination_hash": item, "hops": hops})
+ except Exception:
+ paths.append({"destination_hash": item, "hops": None})
+ return {"paths": paths}
+
+ def invoke(self, plugin_id: str, method: str, args: dict[str, Any] | None = None) -> Any:
+ record = self._require_plugin(plugin_id)
+ if not record.enabled:
+ raise PermissionError("plugin is disabled")
+ args = args or {}
+ if method == "callManager":
+ return self.call_manager(plugin_id, args.get("capability"), args.get("args") or {})
+ if method == "getState":
+ watched = self.storage_get(plugin_id, "watched_nodes")
+ return {"watched_nodes": json.loads(watched) if watched else []}
+ if method == "setWatchedNodes":
+ nodes = args.get("nodes") or []
+ self.storage_set(plugin_id, "watched_nodes", json.dumps(nodes))
+ return {"ok": True}
+ if method == "readPaths":
+ return self.call_manager(plugin_id, "destinationPath.read", args)
+ backend = record.manifest.get("backend")
+ if not backend:
+ raise ValueError(f"unknown method: {method}")
+ return self._invoke_wasm(record, method, args or {})
+
+ def _invoke_wasm(self, record: PluginRecord, method: str, args: dict[str, Any]) -> Any:
+ wasmtime = self._load_wasmtime()
+ backend = record.manifest["backend"]
+ wasm_path = os.path.join(record.install_path, backend["entry"])
+ if not os.path.isfile(wasm_path):
+ wasm_path = self._ensure_minimal_wasm(record)
+ engine = wasmtime.Engine()
+ module = wasmtime.Module.from_file(engine, wasm_path)
+ store = wasmtime.Store(engine)
+ store.set_fuel(1_000_000)
+ linker = wasmtime.Linker(engine)
+ logs: list[str] = []
+
+ def host_log(caller, ptr, length) -> None:
+ memory = caller.get("memory")
+ if memory is None:
+ return
+ data = memory.read(store, ptr, ptr + length)
+ logs.append(data.decode("utf-8", errors="replace"))
+
+ linker.define_func(
+ "host",
+ "log",
+ wasmtime.FuncType([wasmtime.ValType.i32(), wasmtime.ValType.i32()], []),
+ host_log,
+ )
+ instance = linker.instantiate(store, module)
+ payload = json.dumps({"method": method, "args": args}).encode("utf-8")
+ memory = instance.exports(store)["memory"]
+ alloc = instance.exports(store).get("alloc")
+ if alloc:
+ ptr = alloc(store, len(payload))
+ else:
+ ptr = 0
+ if memory.data_len(store) < len(payload):
+ memory.grow(store, max(1, (len(payload) - memory.data_len(store) + 65535) // 65536))
+ data = memory.data_ptr(store)
+ data[ptr : ptr + len(payload)] = payload
+ invoke = instance.exports(store)["invoke"]
+ invoke(store, ptr, len(payload), 0)
+ if method == "getState":
+ watched = self.storage_get(record.id, "watched_nodes")
+ return {"watched_nodes": json.loads(watched) if watched else [], "logs": logs}
+ if method == "setWatchedNodes":
+ nodes = args.get("nodes") or []
+ self.storage_set(record.id, "watched_nodes", json.dumps(nodes))
+ return {"ok": True, "logs": logs}
+ return {"ok": True, "logs": logs}
+
+ def _ensure_minimal_wasm(self, record: PluginRecord) -> str:
+ wasmtime = self._load_wasmtime()
+ engine = wasmtime.Engine()
+ module = wasmtime.Module(engine, MINIMAL_PLUGIN_WAT)
+ wasm_path = os.path.join(record.install_path, "backend", "plugin.wasm")
+ os.makedirs(os.path.dirname(wasm_path), exist_ok=True)
+ with open(wasm_path, "wb") as handle:
+ handle.write(module.serialize())
+ return wasm_path
+
+ def dispatch_hook(self, plugin_id: str, hook: str, payload: dict[str, Any]) -> None:
+ record = self._plugins.get(plugin_id)
+ if not record or not record.enabled:
+ return
+ if not self._hook_allowed(record, hook):
+ return
+ try:
+ self._invoke_wasm(record, "on_hook", {"hook": hook, "payload": payload})
+ self._broadcast_plugin_event(plugin_id, hook, payload)
+ except Exception as exc:
+ self.disable(plugin_id, reason=str(exc))
+
+ def on_announce_received(
+ self,
+ aspect: str,
+ destination_hash: bytes,
+ announced_identity: Any,
+ app_data: bytes,
+ announce_packet_hash: bytes,
+ ) -> None:
+ payload = {
+ "aspect": aspect,
+ "destination_hash": destination_hash.hex() if isinstance(destination_hash, bytes) else str(destination_hash),
+ "app_data": app_data.decode("utf-8", errors="replace") if isinstance(app_data, bytes) else str(app_data),
+ "announce_packet_hash": announce_packet_hash.hex()
+ if isinstance(announce_packet_hash, bytes)
+ else str(announce_packet_hash),
+ }
+ for record in list(self._plugins.values()):
+ if record.enabled and self._hook_allowed(record, "announce.received"):
+ self.dispatch_hook(record.id, "announce.received", payload)
+
+ def _register_plugin_hooks(self, record: PluginRecord) -> None:
+ if not self.app:
+ return
+ hooks = (record.manifest.get("permissions") or {}).get("hooks") or []
+ if "announce.received" in hooks and not getattr(self.app, "_plugin_announce_handler_registered", False):
+ from meshchatx.src.backend.announce_handler import AnnounceHandler
+ import RNS
+
+ handler = AnnounceHandler(
+ "meshchatx.plugin",
+ lambda aspect, dh, ai, ad, aph: self.on_announce_received(aspect, dh, ai, ad, aph),
+ )
+ RNS.Transport.register_announce_handler(handler)
+ self.app._plugin_announce_handler_registered = True
+ self.app._plugin_announce_handler = handler
+
+ def _unregister_plugin_hooks(self, record: PluginRecord) -> None:
+ if not self.app:
+ return
+ any_enabled_hooks = any(
+ record.enabled and "announce.received" in ((p.manifest.get("permissions") or {}).get("hooks") or [])
+ for p in self._plugins.values()
+ )
+ if any_enabled_hooks:
+ return
+ handler = getattr(self.app, "_plugin_announce_handler", None)
+ if not handler:
+ return
+ try:
+ import RNS
+
+ if handler in RNS.Transport.announce_handlers:
+ RNS.Transport.announce_handlers.remove(handler)
+ self.app._plugin_announce_handler_registered = False
+ self.app._plugin_announce_handler = None
+ except Exception:
+ pass
+
+ def _broadcast_plugin_event(self, plugin_id: str, event: str, payload: dict[str, Any]) -> None:
+ if not self.app:
+ return
+ from meshchatx.src.backend.async_utils import AsyncUtils
+ import json as json_module
+
+ message = json_module.dumps(
+ {
+ "type": "plugin.event",
+ "plugin_id": plugin_id,
+ "event": event,
+ "payload": payload,
+ }
+ )
+ AsyncUtils.run_async(self.app.websocket_broadcast(message))
+
+ def install_bundled_examples(self) -> None:
+ bundled_root = os.path.join(os.path.dirname(__file__), "data", "plugins")
+ if not os.path.isdir(bundled_root):
+ return
+ for name in sorted(os.listdir(bundled_root)):
+ source = os.path.join(bundled_root, name)
+ if not os.path.isdir(source):
+ continue
+ manifest_path = os.path.join(source, "plugin.json")
+ if not os.path.isfile(manifest_path):
+ continue
+ with open(manifest_path, encoding="utf-8") as handle:
+ manifest = json.load(handle)
+ if manifest.get("id") not in self._plugins:
+ self.install_from_directory(source)
diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index 6b49a8e1..b5942d74 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -228,203 +228,31 @@
<!-- navigation -->
<div class="flex-1">
<ul class="py-3 pr-2 space-y-1">
- <!-- messages -->
- <li>
- <SidebarLink :to="{ name: 'messages' }" :is-collapsed="isSidebarCollapsed">
+ <li v-for="item in visibleNavItems" :key="item.id" v-if="isNavItemVisible(item)">
+ <SidebarLink :to="item.route" :is-collapsed="isSidebarCollapsed">
<template #icon>
<MaterialDesignIcon
- icon-name="message-text"
+ :icon-name="item.icon"
class="w-6 h-6 text-gray-700 dark:text-white"
/>
</template>
<template #text>
- <span>{{ $t("app.messages") }}</span>
- <span v-if="unreadConversationsCount > 0" class="ml-auto mr-2">{{
- unreadConversationsCount
- }}</span>
- </template>
- </SidebarLink>
- </li>
-
- <!-- telephone -->
- <li>
- <SidebarLink :to="{ name: 'call' }" :is-collapsed="isSidebarCollapsed">
- <template #icon>
- <MaterialDesignIcon
- icon-name="phone"
- class="w-6 h-6 text-gray-700 dark:text-gray-200"
- />
- </template>
- <template #text>{{ $t("app.audio_calls") }}</template>
- </SidebarLink>
- </li>
-
- <!-- contacts -->
- <li>
- <SidebarLink :to="{ name: 'contacts' }" :is-collapsed="isSidebarCollapsed">
- <template #icon>
- <MaterialDesignIcon
- icon-name="account-multiple"
- class="w-6 h-6 text-gray-700 dark:text-white"
- />
- </template>
- <template #text>{{ $t("app.contacts") }}</template>
- </SidebarLink>
- </li>
-
- <!-- relay chat -->
- <li v-if="rrcEnabled">
- <SidebarLink :to="{ name: 'relay-chat' }" :is-collapsed="isSidebarCollapsed">
- <template #icon>
- <MaterialDesignIcon
- icon-name="forum"
- class="w-6 h-6 text-gray-700 dark:text-gray-200"
- />
- </template>
- <template #text>
- <span>{{ $t("app.relay_chat") }}</span>
+ <span>{{ $t(item.labelKey) }}</span>
+ <span
+ v-if="getNavBadgeCount(item) > 0 && !item.badge?.pill"
+ class="ml-auto mr-2"
+ >
+ {{ getNavBadgeCount(item) }}
+ </span>
<span
- v-if="relayChatUnreadCount > 0"
+ v-else-if="getNavBadgeCount(item) > 0 && item.badge?.pill"
class="ml-auto mr-2 min-w-[1.25rem] rounded-full bg-red-500 px-1.5 py-0.5 text-center text-xs font-bold text-white"
>
- {{ relayChatUnreadCount >= 1000 ? "999+" : relayChatUnreadCount }}
+ {{ formatNavBadgeCount(item) }}
</span>
</template>
</SidebarLink>
</li>
-
- <!-- nomad network -->
- <li>
- <SidebarLink :to="{ name: 'nomadnetwork' }" :is-collapsed="isSidebarCollapsed">
- <template #icon>
- <MaterialDesignIcon
- icon-name="earth"
- class="w-6 h-6 text-gray-700 dark:text-gray-200"
- />
- </template>
- <template #text>{{ $t("app.nomad_network") }}</template>
- </SidebarLink>
- </li>
-
- <!-- map -->
- <li>
- <SidebarLink :to="{ name: 'map' }" :is-collapsed="isSidebarCollapsed">
- <template #icon>
- <MaterialDesignIcon
- icon-name="map"
- class="w-6 h-6 text-gray-700 dark:text-gray-200"
- />
- </template>
- <template #text>{{ $t("app.map") }}</template>
- </SidebarLink>
- </li>
-
- <!-- archives -->
- <li>
- <SidebarLink :to="{ name: 'archives' }" :is-collapsed="isSidebarCollapsed">
- <template #icon>
- <MaterialDesignIcon
- icon-name="archive"
- class="w-6 h-6 text-gray-700 dark:text-gray-200"
- />
- </template>
- <template #text>{{ $t("app.archives") }}</template>
- </SidebarLink>
- </li>
-
- <!-- tools -->
- <li>
- <SidebarLink :to="{ name: 'tools' }" :is-collapsed="isSidebarCollapsed">
- <template #icon>
- <MaterialDesignIcon
- icon-name="wrench"
- class="size-6 text-gray-700 dark:text-gray-200"
- />
- </template>
- <template #text>{{ $t("app.tools") }}</template>
- </SidebarLink>
- </li>
-
- <!-- interfaces -->
- <li>
- <SidebarLink :to="{ name: 'interfaces' }" :is-collapsed="isSidebarCollapsed">
- <template #icon>
- <MaterialDesignIcon
- icon-name="router"
- class="w-6 h-6 text-gray-700 dark:text-gray-200"
- />
- </template>
- <template #text>{{ $t("app.interfaces") }}</template>
- </SidebarLink>
- </li>
-
- <!-- network visualiser -->
- <li>
- <SidebarLink
- :to="{ name: 'network-visualiser' }"
- :is-collapsed="isSidebarCollapsed"
- >
- <template #icon>
- <MaterialDesignIcon
- icon-name="hub"
- class="w-6 h-6 text-gray-700 dark:text-gray-200"
- />
- </template>
- <template #text>{{ $t("app.network_visualiser") }}</template>
- </SidebarLink>
- </li>
-
- <!-- banished -->
- <li>
- <SidebarLink :to="{ name: 'blocked' }" :is-collapsed="isSidebarCollapsed">
- <template #icon>
- <MaterialDesignIcon
- icon-name="gavel"
- class="w-6 h-6 text-gray-700 dark:text-gray-200"
- />
- </template>
- <template #text>{{ $t("banishment.title") }}</template>
- </SidebarLink>
- </li>
-
- <!-- settings -->
- <li>
- <SidebarLink :to="{ name: 'settings' }" :is-collapsed="isSidebarCollapsed">
- <template #icon>
- <MaterialDesignIcon
- icon-name="cog"
- class="size-6 text-gray-700 dark:text-gray-200"
- />
- </template>
- <template #text>{{ $t("app.settings") }}</template>
- </SidebarLink>
- </li>
-
- <!-- identities -->
- <li>
- <SidebarLink :to="{ name: 'identities' }" :is-collapsed="isSidebarCollapsed">
- <template #icon>
- <MaterialDesignIcon
- icon-name="badge-account"
- class="size-6 text-gray-700 dark:text-gray-200"
- />
- </template>
- <template #text>{{ $t("app.identities") }}</template>
- </SidebarLink>
- </li>
-
- <!-- info -->
- <li>
- <SidebarLink :to="{ name: 'about' }" :is-collapsed="isSidebarCollapsed">
- <template #icon>
- <MaterialDesignIcon
- icon-name="information"
- class="size-6 text-gray-700 dark:text-gray-200"
- />
- </template>
- <template #text>{{ $t("app.about") }}</template>
- </SidebarLink>
- </li>
</ul>
</div>
@@ -765,6 +593,8 @@ import KeyboardShortcuts from "../js/KeyboardShortcuts";
import ElectronUtils from "../js/ElectronUtils";
import { postRequestPath } from "../js/reticulumPathfinding.js";
import ToneGenerator from "../js/ToneGenerator";
+import { listNavItems } from "../js/registries/navRegistry.js";
+import { onWsEvent, offWsEvent } from "../js/registries/wsEventRegistry.js";
import logoUrl from "../assets/images/logo.png";
import { loadFeatureSidebarCollapsed, saveFeatureSidebarCollapsed } from "../js/browserLayoutStore";
@@ -847,6 +677,7 @@ export default {
identitySwitchDedupeHash: null,
identitySwitchDedupeAt: 0,
+ shellWsHandlerCleanups: [],
};
},
computed: {
@@ -868,6 +699,9 @@ export default {
rrcEnabled() {
return GlobalState.config?.rrc_enabled !== false;
},
+ visibleNavItems() {
+ return listNavItems();
+ },
isSyncingPropagationNode() {
return [
"path_requested",
@@ -1004,6 +838,32 @@ export default {
window.addEventListener("keydown", this.onRingtoneUnlockGesture, true);
},
methods: {
+ isNavItemVisible(item) {
+ if (item.visibleWhen === "rrcEnabled") {
+ return this.rrcEnabled;
+ }
+ return true;
+ },
+ getNavBadgeCount(item) {
+ if (!item.badge?.source) {
+ return 0;
+ }
+ if (item.badge.source === "unreadConversationsCount") {
+ return this.unreadConversationsCount;
+ }
+ if (item.badge.source === "relayChatUnreadCount") {
+ return this.relayChatUnreadCount;
+ }
+ return 0;
+ },
+ formatNavBadgeCount(item) {
+ const count = this.getNavBadgeCount(item);
+ const cap = item.badge?.cap;
+ if (cap && count >= cap) {
+ return `${cap - 1}+`;
+ }
+ return count;
+ },
onRingtoneUnlockGesture() {
if (!this.ringtoneAutoplayBlocked) {
return;
@@ -1045,9 +905,9 @@ export default {
}
this.shellRunning = true;
WebSocketConnection.connect();
- WebSocketConnection.on("message", this.onWebsocketMessage);
WebSocketConnection.on("disconnected", this.onWsShellDisconnected);
WebSocketConnection.on("connected", this.onWsShellConnected);
+ this.registerShellWsHandlers();
GlobalEmitter.on("identity-switching-start", this.onIdentitySwitchingStartShell);
GlobalEmitter.on("identity-switched-apply", this.onIdentitySwitchedApplyShell);
GlobalEmitter.on("sync-propagation-node", this.onSyncPropagationNodeShell);
@@ -1090,9 +950,9 @@ export default {
this.appInfoInterval = null;
clearInterval(this.unreadCountInterval);
this.unreadCountInterval = null;
- WebSocketConnection.off("message", this.onWebsocketMessage);
WebSocketConnection.off("disconnected", this.onWsShellDisconnected);
WebSocketConnection.off("connected", this.onWsShellConnected);
+ this.unregisterShellWsHandlers();
GlobalEmitter.off("identity-switching-start", this.onIdentitySwitchingStartShell);
GlobalEmitter.off("identity-switched-apply", this.onIdentitySwitchedApplyShell);
GlobalEmitter.off("sync-propagation-node", this.onSyncPropagationNodeShell);
@@ -1366,51 +1226,59 @@ export default {
const match = hash.match(/popout=([^&]+)/);
return match ? decodeURIComponent(match[1]) : null;
},
- async onWebsocketMessage(message) {
- const json = JSON.parse(message.data);
- switch (json.type) {
- case "config": {
+ registerShellWsHandlers() {
+ this.unregisterShellWsHandlers();
+ const handlers = this.getShellWsHandlers();
+ for (const [type, handler] of Object.entries(handlers)) {
+ const bound = (payload) => handler(payload);
+ onWsEvent(type, bound);
+ this.shellWsHandlerCleanups.push(() => offWsEvent(type, bound));
+ }
+ },
+ unregisterShellWsHandlers() {
+ for (const cleanup of this.shellWsHandlerCleanups) {
+ cleanup();
+ }
+ this.shellWsHandlerCleanups = [];
+ },
+ getShellWsHandlers() {
+ return {
+ config: (json) => {
const next = json?.config;
if (next && typeof next === "object") {
mergeGlobalConfig(next);
this.config = next;
this.displayName = next.display_name;
}
- break;
- }
- case "keyboard_shortcuts": {
+ },
+ keyboard_shortcuts: (json) => {
KeyboardShortcuts.setShortcuts(json.shortcuts);
- break;
- }
- case "announced": {
- // we just announced, update config so we can show the new last updated at
+ },
+ announced: () => {
this.getConfig();
- break;
- }
- case "telephone_ringing": {
+ },
+ telephone_ringing: (json) => {
if (this.config?.do_not_disturb_enabled) {
- break;
+ return;
}
if (this.config?.telephone_allow_calls_from_contacts_only && !json.is_contact) {
- break;
+ return;
}
if (this.initiationStatus) {
- break;
+ return;
}
NotificationUtils.showIncomingCallNotification(
json.remote_identity_name || json.remote_identity_hash
);
this.updateTelephoneStatus();
this.playRingtone();
- break;
- }
- case "telephone_missed_call": {
+ },
+ telephone_missed_call: (json) => {
NotificationUtils.showMissedCallNotification(
json.remote_identity_name || json.remote_identity_hash
);
- break;
- }
- case "telephone_initiation_status": {
+ },
+ telephone_initiation_status: (json) => {
this.initiationStatus = json.status;
this.initiationTargetHash = json.target_hash;
this.initiationTargetName = json.target_name;
@@ -1423,24 +1291,21 @@ export default {
} else if (this.initiationStatus === null) {
this.toneGenerator.stop();
}
- break;
- }
- case "new_voicemail": {
+ },
+ new_voicemail: (json) => {
NotificationUtils.showNewVoicemailNotification(
json.remote_identity_name || json.remote_identity_hash
);
this.updateTelephoneStatus();
- break;
- }
- case "telephone_call_established": {
+ },
+ telephone_call_established: () => {
this.stopRingtone();
this.ringtonePlayer = null;
this.toneGenerator.stop();
NotificationUtils.cancelIncomingCallNotification();
this.updateTelephoneStatus();
- break;
- }
- case "telephone_call_ended": {
+ },
+ telephone_call_ended: () => {
this.stopRingtone();
NotificationUtils.cancelIncomingCallNotification();
this.ringtonePlayer = null;
@@ -1449,36 +1314,26 @@ export default {
this.toneGenerator.playBusyTone();
}
this.updateTelephoneStatus();
- break;
- }
- case "blocked_destinations": {
+ },
+ blocked_destinations: (json) => {
GlobalState.blockedDestinations = json.blocked_destinations || [];
- break;
- }
- case "rrc.message": {
+ },
+ "rrc.message": (json) => {
if (json.mention || json.message?.mention) {
this.updateRelayChatUnreadCount();
}
- break;
- }
- case "rrc.change": {
+ },
+ "rrc.change": () => {
this.updateRelayChatUnreadCount();
- break;
- }
- case "lxmf.delivery": {
+ },
+ "lxmf.delivery": (json) => {
if (this.config?.do_not_disturb_enabled) {
- break;
+ return;
}
if (json.sieve_suppress_notifications) {
- break;
+ return;
}
-
- // Update sidebar unread count so the badge appears
- // immediately even when not on the Messages page.
this.updateUnreadConversationsCount();
-
- // show notification for new messages if window is not focussed
- // only for incoming messages from people (with content)
if (
!document.hasFocus() &&
json.lxmf_message?.is_incoming === true &&
@@ -1489,9 +1344,8 @@ export default {
json.lxmf_message?.content
);
}
- break;
- }
- case "lxm.ingest_uri.result": {
+ },
+ "lxm.ingest_uri.result": async (json) => {
if (json.ingest_type === "map_view" && json.map_query) {
const mq = json.map_query;
const query = {
@@ -1511,7 +1365,7 @@ export default {
} else if (json.message) {
ToastUtils.info(json.message);
}
- break;
+ return;
}
if (json.ingest_type === "docs_view") {
const dq = json.docs_query;
@@ -1529,7 +1383,7 @@ export default {
} else if (json.message) {
ToastUtils.info(json.message);
}
- break;
+ return;
}
if (json.status === "success") {
ToastUtils.success(json.message);
@@ -1540,19 +1394,16 @@ export default {
} else {
ToastUtils.info(json.message);
}
- break;
- }
- case "database_health_warning": {
+ },
+ database_health_warning: (json) => {
if (json.issues && json.issues.length > 0) {
ToastUtils.warning(json.issues.join(" ") || "Database issue detected.", 8000);
}
- break;
- }
- case "identity_switched": {
+ },
+ identity_switched: async (json) => {
await this.applyIdentitySwitched(json);
- break;
- }
- case "rncp.receive.completed": {
+ },
+ "rncp.receive.completed": (json) => {
if (this.$route?.name !== "rncp") {
const detail =
json.status === "completed" && json.saved_path
@@ -1567,9 +1418,8 @@ export default {
ToastUtils.error(`${this.$t("rncp.receive_failed")}${detail ? ": " + detail : ""}`);
}
}
- break;
- }
- }
+ },
+ };
},
async getAppInfo() {
try {
diff --git a/meshchatx/src/frontend/components/CommandPalette.vue b/meshchatx/src/frontend/components/CommandPalette.vue
index c98001ab..1286c7b5 100644
--- a/meshchatx/src/frontend/components/CommandPalette.vue
+++ b/meshchatx/src/frontend/components/CommandPalette.vue
@@ -114,6 +114,7 @@ import LxmfUserIcon from "./LxmfUserIcon.vue";
import GlobalEmitter from "../js/GlobalEmitter";
import ToastUtils from "../js/ToastUtils";
+import { listCommands } from "../js/registries/commandRegistry.js";
export default {
name: "CommandPalette",
@@ -125,203 +126,12 @@ export default {
highlightedId: null,
peers: [],
contacts: [],
- actions: [
- {
- id: "nav-messages",
- title: "nav_messages",
- description: "nav_messages_desc",
- icon: "message-text",
- type: "navigation",
- route: { name: "messages" },
- },
- {
- id: "nav-call",
- title: "nav_call",
- description: "nav_call_desc",
- icon: "phone",
- type: "navigation",
- route: { name: "call" },
- },
- {
- id: "nav-nomad",
- title: "nav_nomad",
- description: "nav_nomad_desc",
- icon: "earth",
- type: "navigation",
- route: { name: "nomadnetwork" },
- },
- {
- id: "nav-map",
- title: "nav_map",
- description: "nav_map_desc",
- icon: "map",
- type: "navigation",
- route: { name: "map" },
- },
- {
- id: "nav-paper",
- title: "nav_paper",
- description: "nav_paper_desc",
- icon: "qrcode",
- type: "navigation",
- route: { name: "paper-message" },
- },
- {
- id: "nav-settings",
- title: "nav_settings",
- description: "nav_settings_desc",
- icon: "cog",
- type: "navigation",
- route: { name: "settings" },
- },
- {
- id: "nav-ping",
- title: "nav_ping",
- description: "nav_ping_desc",
- icon: "radar",
- type: "navigation",
- route: { name: "ping" },
- },
- {
- id: "nav-rnprobe",
- title: "nav_rnprobe",
- description: "nav_rnprobe_desc",
- icon: "radar",
- type: "navigation",
- route: { name: "rnprobe" },
- },
- {
- id: "nav-rncp",
- title: "nav_rncp",
- description: "nav_rncp_desc",
- icon: "swap-horizontal",
- type: "navigation",
- route: { name: "rncp" },
- },
- {
- id: "nav-rnstatus",
- title: "nav_rnstatus",
- description: "nav_rnstatus_desc",
- icon: "chart-line",
- type: "navigation",
- route: { name: "rnstatus" },
- },
- {
- id: "nav-rnpath",
- title: "nav_rnpath",
- description: "nav_rnpath_desc",
- icon: "route",
- type: "navigation",
- route: { name: "rnpath" },
- },
- {
- id: "nav-rnpath-trace",
- title: "nav_rnpath_trace",
- description: "nav_rnpath_trace_desc",
- icon: "map-marker-path",
- type: "navigation",
- route: { name: "rnpath-trace" },
- },
- {
- id: "nav-translator",
- title: "nav_translator",
- description: "nav_translator_desc",
- icon: "translate",
- type: "navigation",
- route: { name: "translator" },
- },
- {
- id: "nav-forwarder",
- title: "nav_forwarder",
- description: "nav_forwarder_desc",
- icon: "email-send-outline",
- type: "navigation",
- route: { name: "forwarder" },
- },
- {
- id: "nav-documentation",
- title: "nav_documentation",
- description: "nav_documentation_desc",
- icon: "book-open-variant",
- type: "navigation",
- route: { name: "documentation" },
- },
- {
- id: "nav-repository-server",
- title: "nav_repository_server",
- description: "nav_repository_server_desc",
- icon: "package-variant",
- type: "navigation",
- route: { name: "repository-server" },
- },
- {
- id: "nav-micron-editor",
- title: "nav_micron_editor",
- description: "nav_micron_editor_desc",
- icon: "code-tags",
- type: "navigation",
- route: { name: "micron-editor" },
- },
- {
- id: "nav-reticulum-config-editor",
- title: "nav_reticulum_config_editor",
- description: "nav_reticulum_config_editor_desc",
- icon: "file-cog",
- type: "navigation",
- route: { name: "reticulum-config-editor" },
- },
- {
- id: "nav-rnode-flasher",
- title: "nav_rnode_flasher",
- description: "nav_rnode_flasher_desc",
- icon: "flash",
- type: "navigation",
- route: { name: "rnode-flasher" },
- },
- {
- id: "nav-debug-logs",
- title: "nav_debug_logs",
- description: "nav_debug_logs_desc",
- icon: "console",
- type: "navigation",
- route: { name: "debug-logs" },
- },
- {
- id: "action-sync",
- title: "action_sync",
- description: "action_sync_desc",
- icon: "refresh",
- type: "action",
- action: "sync",
- },
- {
- id: "action-compose",
- title: "action_compose",
- description: "action_compose_desc",
- icon: "email-plus",
- type: "action",
- action: "compose",
- },
- {
- id: "action-getting-started",
- title: "action_getting_started",
- description: "action_getting_started_desc",
- icon: "help-circle",
- type: "action",
- action: "show-tutorial",
- },
- {
- id: "action-changelog",
- title: "action_changelog",
- description: "action_changelog_desc",
- icon: "history",
- type: "action",
- action: "show-changelog",
- },
- ],
};
},
computed: {
+ actions() {
+ return listCommands();
+ },
allResults() {
const results = this.actions.map((action) => ({
...action,
diff --git a/meshchatx/src/frontend/components/plugins/PluginPage.vue b/meshchatx/src/frontend/components/plugins/PluginPage.vue
new file mode 100644
index 00000000..8134d02c
--- /dev/null
+++ b/meshchatx/src/frontend/components/plugins/PluginPage.vue
@@ -0,0 +1,54 @@
+<!-- SPDX-License-Identifier: 0BSD -->
+
+<template>
+ <div class="h-full overflow-y-auto p-4 sm:p-6">
+ <div class="mx-auto max-w-3xl rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 p-4 sm:p-6">
+ <PluginSlotRenderer
+ :plugin-id="pluginId"
+ :descriptor="descriptor"
+ @action="onAction"
+ @input="onInput"
+ />
+ </div>
+ </div>
+</template>
+
+<script>
+import PluginSlotRenderer from "./PluginSlotRenderer.vue";
+import { pluginHost } from "../../js/plugins/PluginHost.js";
+
+export default {
+ name: "PluginPage",
+ components: { PluginSlotRenderer },
+ props: {
+ pluginId: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ descriptor: null,
+ };
+ },
+ mounted() {
+ this.uiListener = (event) => {
+ if (event.detail?.pluginId === this.pluginId) {
+ this.descriptor = event.detail.descriptor;
+ }
+ };
+ window.addEventListener("meshchatx-plugin-ui", this.uiListener);
+ },
+ beforeUnmount() {
+ window.removeEventListener("meshchatx-plugin-ui", this.uiListener);
+ },
+ methods: {
+ onAction(actionId) {
+ pluginHost.postAction(this.pluginId, actionId);
+ },
+ onInput(payload) {
+ pluginHost.postInput(this.pluginId, payload.id, payload.value);
+ },
+ },
+};
+</script>
diff --git a/meshchatx/src/frontend/components/plugins/PluginSlotRenderer.vue b/meshchatx/src/frontend/components/plugins/PluginSlotRenderer.vue
new file mode 100644
index 00000000..22dbdda7
--- /dev/null
+++ b/meshchatx/src/frontend/components/plugins/PluginSlotRenderer.vue
@@ -0,0 +1,104 @@
+<!-- SPDX-License-Identifier: 0BSD -->
+
+<template>
+ <div class="plugin-slot space-y-4">
+ <template v-for="(node, index) in nodes" :key="index">
+ <component
+ :is="resolveComponent(node)"
+ v-bind="nodeProps(node)"
+ @click="onNodeAction(node)"
+ @input="onNodeInput(node, $event)"
+ />
+ </template>
+ </div>
+</template>
+
+<script>
+export default {
+ name: "PluginSlotRenderer",
+ props: {
+ descriptor: {
+ type: Object,
+ default: null,
+ },
+ pluginId: {
+ type: String,
+ required: true,
+ },
+ },
+ emits: ["action", "input"],
+ computed: {
+ nodes() {
+ if (!this.descriptor) {
+ return [];
+ }
+ if (this.descriptor.type === "column" && Array.isArray(this.descriptor.children)) {
+ return this.descriptor.children;
+ }
+ return [this.descriptor];
+ },
+ },
+ methods: {
+ resolveComponent(node) {
+ switch (node.type) {
+ case "text":
+ return "p";
+ case "button":
+ return "button";
+ case "input":
+ return "input";
+ case "list":
+ return "div";
+ case "row":
+ return "div";
+ default:
+ return "div";
+ }
+ },
+ nodeProps(node) {
+ if (node.type === "text") {
+ return {
+ class:
+ node.variant === "title"
+ ? "text-lg font-semibold text-gray-900 dark:text-gray-100"
+ : "text-sm text-gray-700 dark:text-gray-300",
+ textContent: node.value,
+ };
+ }
+ if (node.type === "button") {
+ return {
+ class: "px-3 py-2 rounded-md bg-blue-600 text-white text-sm hover:bg-blue-700",
+ type: "button",
+ "data-action-id": node.id,
+ };
+ }
+ if (node.type === "input") {
+ return {
+ class: "w-full rounded-md border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-3 py-2 text-sm",
+ type: "text",
+ placeholder: node.placeholder || "",
+ "data-input-id": node.id,
+ value: node.value || "",
+ };
+ }
+ if (node.type === "list") {
+ return { class: "space-y-2" };
+ }
+ if (node.type === "row") {
+ return { class: "flex items-center justify-between gap-3 text-sm" };
+ }
+ return {};
+ },
+ onNodeAction(node) {
+ if (node.type === "button" && node.id) {
+ this.$emit("action", node.id);
+ }
+ },
+ onNodeInput(node, event) {
+ if (node.type === "input" && node.id) {
+ this.$emit("input", { id: node.id, value: event.target.value });
+ }
+ },
+ },
+};
+</script>
diff --git a/meshchatx/src/frontend/components/settings/PluginsSettingsSection.vue b/meshchatx/src/frontend/components/settings/PluginsSettingsSection.vue
new file mode 100644
index 00000000..a5fad059
--- /dev/null
+++ b/meshchatx/src/frontend/components/settings/PluginsSettingsSection.vue
@@ -0,0 +1,139 @@
+<!-- SPDX-License-Identifier: 0BSD -->
+
+<template>
+ <SettingsSectionBlock
+ v-show="visible"
+ :title="$t('plugins.settings.title')"
+ :description="$t('plugins.settings.description')"
+ >
+ <div class="space-y-4">
+ <div
+ v-for="plugin in plugins"
+ :key="plugin.id"
+ class="rounded-lg border border-gray-200 dark:border-zinc-800 p-4 space-y-3"
+ >
+ <div class="flex flex-wrap items-start justify-between gap-3">
+ <div>
+ <h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">{{ plugin.name }}</h3>
+ <p class="text-sm text-gray-600 dark:text-gray-400">{{ plugin.description }}</p>
+ <p class="text-xs text-gray-500 dark:text-gray-500 mt-1">{{ plugin.id }} · v{{ plugin.version }}</p>
+ </div>
+ <div class="flex gap-2">
+ <button
+ v-if="!plugin.enabled"
+ type="button"
+ class="px-3 py-1.5 rounded-md bg-blue-600 text-white text-sm"
+ @click="enablePlugin(plugin.id)"
+ >
+ {{ $t("plugins.settings.enable") }}
+ </button>
+ <button
+ v-else
+ type="button"
+ class="px-3 py-1.5 rounded-md bg-zinc-600 text-white text-sm"
+ @click="disablePlugin(plugin.id)"
+ >
+ {{ $t("plugins.settings.disable") }}
+ </button>
+ <button
+ type="button"
+ class="px-3 py-1.5 rounded-md border border-red-300 text-red-600 text-sm"
+ @click="removePlugin(plugin.id)"
+ >
+ {{ $t("plugins.settings.remove") }}
+ </button>
+ </div>
+ </div>
+ <div v-if="permissionLines(plugin).length" class="text-sm text-gray-700 dark:text-gray-300">
+ <p class="font-medium">{{ $t("plugins.settings.permissions") }}</p>
+ <ul class="list-disc pl-5">
+ <li v-for="line in permissionLines(plugin)" :key="line">{{ line }}</li>
+ </ul>
+ </div>
+ <p v-if="plugin.auto_disabled_reason" class="text-sm text-amber-700 dark:text-amber-300">
+ {{ $t("plugins.settings.auto_disabled", { reason: plugin.auto_disabled_reason }) }}
+ </p>
+ </div>
+ <label class="block">
+ <span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t("plugins.settings.install_zip") }}</span>
+ <input type="file" accept=".zip,application/zip" class="mt-1 block w-full text-sm" @change="onInstallFile" />
+ </label>
+ </div>
+ </SettingsSectionBlock>
+</template>
+
+<script>
+import SettingsSectionBlock from "./SettingsSectionBlock.vue";
+import ToastUtils from "../../js/ToastUtils";
+import { manifestPermissionSummary } from "../../js/plugins/pluginManifest.js";
+import { pluginHost } from "../../js/plugins/PluginHost.js";
+import { onWsEvent, offWsEvent } from "../../js/registries/wsEventRegistry.js";
+
+export default {
+ name: "PluginsSettingsSection",
+ components: { SettingsSectionBlock },
+ props: {
+ visible: {
+ type: Boolean,
+ default: true,
+ },
+ },
+ data() {
+ return {
+ plugins: [],
+ };
+ },
+ mounted() {
+ void this.refresh();
+ this.onPluginDisabled = (payload) => {
+ if (payload?.event === "plugin.disabled") {
+ ToastUtils.warning(this.$t("plugins.settings.kill_switch", { reason: payload?.payload?.reason || "" }));
+ void this.refresh();
+ }
+ };
+ onWsEvent("plugin.event", this.onPluginDisabled);
+ },
+ beforeUnmount() {
+ offWsEvent("plugin.event", this.onPluginDisabled);
+ },
+ methods: {
+ permissionLines(plugin) {
+ return manifestPermissionSummary(plugin.manifest || { permissions: plugin.permissions || {} });
+ },
+ async refresh() {
+ const response = await window.api.get("/api/v1/plugins");
+ this.plugins = response.data?.plugins || [];
+ },
+ async enablePlugin(pluginId) {
+ await window.api.post(`/api/v1/plugins/${encodeURIComponent(pluginId)}/enable`);
+ await pluginHost.loadEnabledPlugins(window.api, this.$i18n?.messages?.[this.$i18n.locale]?.plugins || {});
+ await this.refresh();
+ ToastUtils.success(this.$t("plugins.settings.enabled"));
+ },
+ async disablePlugin(pluginId) {
+ await window.api.post(`/api/v1/plugins/${encodeURIComponent(pluginId)}/disable`);
+ pluginHost.unloadPlugin(pluginId);
+ await this.refresh();
+ ToastUtils.info(this.$t("plugins.settings.disabled"));
+ },
+ async removePlugin(pluginId) {
+ await window.api.delete(`/api/v1/plugins/${encodeURIComponent(pluginId)}`);
+ pluginHost.unloadPlugin(pluginId);
+ await this.refresh();
+ ToastUtils.info(this.$t("plugins.settings.removed"));
+ },
+ async onInstallFile(event) {
+ const file = event.target.files?.[0];
+ if (!file) {
+ return;
+ }
+ const formData = new FormData();
+ formData.append("archive", file);
+ await window.api.post("/api/v1/plugins/install", formData);
+ await this.refresh();
+ ToastUtils.success(this.$t("plugins.settings.installed"));
+ event.target.value = "";
+ },
+ },
+};
+</script>
diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index 6e2c6717..810e3f12 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -692,6 +692,8 @@
</div>
</section>
+ <PluginsSettingsSection :visible="showSection('plugins')" />
+
<!-- Telephony Settings -->
<section v-show="showSection('telephony')" class="settings-section break-inside-avoid">
<header class="settings-section__header">
@@ -2802,8 +2804,10 @@ import {
import { normalizeRetentionValue } from "../../js/localMessageRetention";
import { matchesSettingSearch, normalizeSearchString } from "../../js/settingsSearchUtils";
import { DEFAULT_SETTINGS_TAB, normalizeSettingsTabId, SETTINGS_TABS } from "../../js/settings/settingsTabs.js";
+import { getAllSettingsSectionKeywords } from "../../js/registries/settingsSectionRegistry.js";
import { isMicronWasmBundled } from "../../js/MicronWasmLoader.js";
import MicronWasmUpdateModal from "./MicronWasmUpdateModal.vue";
+import PluginsSettingsSection from "./PluginsSettingsSection.vue";
export default {
name: "SettingsPage",
@@ -2814,6 +2818,7 @@ export default {
SettingsSectionBlock,
SettingsNav,
StickerPacksManager,
+ PluginsSettingsSection,
MicronWasmUpdateModal,
},
data() {
@@ -2930,321 +2935,12 @@ export default {
gifImportReplaceDuplicates: false,
visualiserShowDisabledInterfaces: false,
visualiserShowDiscoveredInterfaces: false,
- sectionKeywords: {
- telephony: [
- "Telephony",
- "Telephone",
- "LXST",
- "Enable Telephone",
- "voice",
- "calling",
- "call",
- "mesh network",
- ],
- strangerProtection: [
- "Security",
- "app.stranger_protection",
- "app.stranger_protection_description",
- "app.block_stranger_attachments",
- "app.block_stranger_attachments_description",
- "app.block_all_from_strangers",
- "app.block_all_from_strangers_description",
- "app.show_unknown_contact_banner",
- "app.show_unknown_contact_banner_description",
- "app.warn_on_stranger_links",
- "app.warn_on_stranger_links_description",
- "stranger",
- "attachments",
- "trust",
- "block",
- "banner",
- "unknown",
- "contact",
- "links",
- ],
- visualiser: [
- "Visualiser",
- "Network Visualiser",
- "visualiser",
- "graph",
- "mesh",
- "visualiser.show_disabled_interfaces",
- "visualiser.show_discovered_interfaces",
- "offline",
- "discovered",
- ],
- banishment: [
- "Visuals",
- "app.banishment",
- "app.banishment_description",
- "app.banished_effect_enabled",
- "app.banished_effect_description",
- "app.banished_text_label",
- "app.banished_text_description",
- "app.banished_color_label",
- "app.banished_color_description",
- ],
- stickers: [
- "Stickers",
- "stickers.settings_title",
- "stickers.settings_description",
- "stickers.export",
- "stickers.import",
- "stickers.replace_duplicates",
- "sticker_packs.section_title",
- "sticker_packs.create",
- "sticker_packs.install_from_file",
- "sticker_packs.open_editor",
- ],
- gifs: [
- "GIFs",
- "gifs.settings_title",
- "gifs.settings_description",
- "gifs.export",
- "gifs.import",
- "gifs.replace_duplicates",
- ],
- maintenance: [
- "Maintenance",
- "maintenance.title",
- "maintenance.description",
- "maintenance.clear_messages",
- "maintenance.clear_messages_desc",
- "maintenance.clear_announces",
- "maintenance.clear_announces_desc",
- "maintenance.clear_nomadnet_favs",
- "maintenance.clear_nomadnet_favs_desc",
- "maintenance.clear_lxmf_icons",
- "maintenance.clear_lxmf_icons_desc",
- "maintenance.clear_stickers",
- "maintenance.clear_stickers_desc",
- "maintenance.clear_gifs",
- "maintenance.clear_gifs_desc",
- "maintenance.clear_archives",
- "maintenance.clear_archives_desc",
- "maintenance.clear_reticulum_docs",
- "maintenance.clear_reticulum_docs_desc",
- "maintenance.clear_path_table",
- "maintenance.clear_path_table_desc",
- "maintenance.export_messages",
- "maintenance.export_messages_desc",
- "maintenance.import_messages",
- "maintenance.import_messages_desc",
- "maintenance.export_nomadnet_favourites",
- "maintenance.import_nomadnet_favourites",
- "Automatic Backup Limit",
- "Export Folders",
- "Import Folders",
- ],
- desktop: [
- "Desktop",
- "App Behaviour",
- "app.desktop_open_calls_in_separate_window",
- "app.desktop_open_calls_in_separate_window_description",
- "app.desktop_hardware_acceleration_enabled",
- "app.desktop_hardware_acceleration_enabled_description",
- ],
- android: [
- "Android",
- "APK",
- "Bluetooth",
- "Nearby Share",
- "settings.share_apk_heading",
- "settings.share_apk_desc",
- "settings.share_apk",
- "settings.share_apk_short_hint",
- ],
- archiver: ["Browsing", "Page Archiver", "archiver", "archive", "versions", "storage", "flush"],
- nomadRenderer: [
- "NomadNet",
- "NomadNet browser renderer",
- "micron-parser-go",
- "WASM",
- "SHASUMS",
- "micron wasm update",
- "browser",
- "renderer",
- "markdown",
- "HTML",
- "plaintext",
- "micron-parser",
- "index.mu",
- "index.html",
- "default page",
- "settings.nomad_micron_default_engine_title",
- "settings.nomad_micron_default_engine_desc",
- ],
- crawler: ["Discovery", "Smart Crawler", "crawler", "crawl", "retries", "delay", "concurrent"],
- csp: [
- "Security",
- "app.csp_settings",
- "app.csp_description",
- "app.csp_extra_connect_src",
- "app.csp_extra_img_src",
- "app.csp_extra_frame_src",
- "app.csp_extra_script_src",
- "app.csp_extra_style_src",
- "CSP",
- "Content Security Policy",
- ],
- appearance: [
- "Personalise",
- "app.appearance",
- "app.appearance_description",
- "app.theme",
- "app.light_theme",
- "app.dark_theme",
- "app.messages_sidebar_position",
- "app.messages_sidebar_position_left",
- "app.messages_sidebar_position_right",
- "app.messages_multi_pane_enabled",
- "app.messages_multi_pane_enabled_description",
- "app.nomad_tabs_enabled",
- "app.nomad_tabs_enabled_description",
- "app.ui_transparency",
- "app.ui_glass_enabled",
- "app.reset_appearance_defaults",
- "Message Font Size",
- "Icon Size",
- "Message Bubbles",
- "Waiting Color",
- "app.live_preview",
- "app.realtime",
- ],
- language: [
- "i18n",
- "app.language",
- "app.select_language",
- "English",
- "Deutsch",
- "Italiano",
- "Русский",
- "Nederlands",
- "Français",
- "Español",
- "中文",
- ],
- networkSecurity: [
- "RNS Security",
- "Network Security",
- "app.blackhole_integration_enabled",
- "app.blackhole_integration_description",
- "app.announce_limits",
- "app.announce_store_heading",
- "app.announce_store_lxmf",
- "app.announce_store_lxst",
- "app.announce_store_nomad",
- "app.announce_store_prop",
- "app.announce_limit_lxmf",
- "app.announce_limit_nomadnet",
- "app.announce_limit_prop",
- "app.announce_max_stored_heading",
- "app.announce_fetch_limit_heading",
- "app.announce_search_max_fetch",
- "app.discovered_interfaces_max_return",
- ],
- transport: [
- "Reticulum",
- "app.transport_mode",
- "app.transport_description",
- "app.enable_transport_mode",
- "app.transport_toggle_description",
- ],
- interfaces: [
- "Adapters",
- "app.interfaces",
- "app.show_community_interfaces",
- "app.community_interfaces_description",
- ],
- blocked: ["Privacy", "Banished", "Manage Banished users and nodes"],
- auth: ["Security", "Authentication", "password", "Protect your instance with a password"],
- webExposure: [
- "Security",
- "Network exposure",
- "app.web_exposure_title",
- "app.web_exposure_description",
- "app.web_listen_address",
- "app.web_ui_ip_allowlist",
- "app.web_exposure_warning_title",
- "app.landlock_status",
- "allowlist",
- "firewall",
- "VPN",
- "bind",
- "localhost",
- ],
- infrastructure: ["Infrastructure", "Sources & Mirroring", "gitea", "documentation", "download", "urls"],
- messages: [
- "app.lxmf_settings_eyebrow",
- "app.messages",
- "app.messages_description",
- "app.auto_resend_title",
- "app.auto_resend_description",
- "app.retry_attachments_title",
- "app.retry_attachments_description",
- "app.auto_fallback_title",
- "app.auto_fallback_description",
- "app.inbound_stamp_cost",
- "app.inbound_stamp_description",
- "app.inbound_stamps_required_title",
- "app.inbound_stamps_required_description",
- "app.flood_protection",
- "app.flood_protection_description",
- "app.flood_protection_enabled",
- "app.flood_threshold",
- "app.flood_max_stamp_cost",
- "app.flood_cooldown",
- ],
- propagation: [
- "LXMF",
- "app.incoming_message_size",
- "app.incoming_message_size_description",
- "app.propagation_nodes",
- "app.propagation_nodes_description",
- "app.browse_nodes",
- "app.run_local_node",
- "app.run_local_node_description",
- "app.auto_select_node",
- "app.auto_select_node_description",
- "app.auto_select_using_label",
- "app.auto_select_pending",
- "app.preferred_propagation_node",
- "app.auto_sync_interval",
- "app.propagation_stamp_cost",
- "app.propagation_stamp_description",
- ],
- location: [
- "app.location",
- "app.location_manage_desc",
- "app.location_source",
- "Map",
- "Location",
- "GPS",
- "manual",
- "latitude",
- "longitude",
- "altitude",
- ],
- privacyData: [
- "app.privacy_data_title",
- "app.privacy_data_description",
- "app.privacy_mode_enabled",
- "app.privacy_mode_description",
- "app.local_message_auto_delete_title",
- "app.local_message_auto_delete_description",
- "app.local_message_auto_delete_age",
- "app.telemetry_enabled",
- "app.telemetry_description",
- "app.telemetry_trusted_peers",
- "ephemeral",
- "retention",
- "Privacy",
- ],
- shortcuts: ["Keyboard Shortcuts", "actions", "workflow"],
- },
};
},
computed: {
+ sectionKeywords() {
+ return getAllSettingsSectionKeywords();
+ },
micronWasmBundledInBuild() {
return isMicronWasmBundled();
},
diff --git a/meshchatx/src/frontend/components/tools/ToolsPage.vue b/meshchatx/src/frontend/components/tools/ToolsPage.vue
index d904ff2e..dd94dd92 100644
--- a/meshchatx/src/frontend/components/tools/ToolsPage.vue
+++ b/meshchatx/src/frontend/components/tools/ToolsPage.vue
@@ -122,6 +122,8 @@
<script>
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
+import { listTools } from "../../js/registries/toolsRegistry.js";
+
export default {
name: "ToolsPage",
components: {
@@ -131,205 +133,12 @@ export default {
return {
rnodeLogoPath: "/rnode-flasher/reticulum_logo_512.png",
searchQuery: "",
- tools: [
- {
- name: "ping",
- route: { name: "ping" },
- icon: "radar",
- iconBg: "tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200",
- titleKey: "tools.ping.title",
- descriptionKey: "tools.ping.description",
- },
- {
- name: "rnprobe",
- route: { name: "rnprobe" },
- icon: "radar",
- iconBg: "tool-card__icon bg-purple-50 text-purple-500 dark:bg-purple-900/30 dark:text-purple-200",
- titleKey: "tools.rnprobe.title",
- descriptionKey: "tools.rnprobe.description",
- },
- {
- name: "rncp",
- route: { name: "rncp" },
- icon: "swap-horizontal",
- iconBg: "tool-card__icon bg-green-50 text-green-500 dark:bg-green-900/30 dark:text-green-200",
- titleKey: "tools.rncp.title",
- descriptionKey: "tools.rncp.description",
- },
- {
- name: "rnsh",
- route: { name: "rnsh" },
- icon: "console-network-outline",
- iconBg: "tool-card__icon bg-indigo-50 text-indigo-600 dark:bg-indigo-900/30 dark:text-indigo-200",
- titleKey: "tools.rnsh.title",
- descriptionKey: "tools.rnsh.description",
- },
- {
- name: "rnstatus",
- route: { name: "rnstatus" },
- icon: "chart-line",
- iconBg: "tool-card__icon bg-orange-50 text-orange-500 dark:bg-orange-900/30 dark:text-orange-200",
- titleKey: "tools.rnstatus.title",
- descriptionKey: "tools.rnstatus.description",
- },
- {
- name: "rnpath",
- route: { name: "rnpath" },
- icon: "route",
- iconBg: "tool-card__icon bg-indigo-50 text-indigo-500 dark:bg-indigo-900/30 dark:text-indigo-200",
- titleKey: "tools.rnpath.title",
- descriptionKey: "tools.rnpath.description",
- },
- {
- name: "rnpath-trace",
- route: { name: "rnpath-trace" },
- icon: "map-marker-path",
- iconBg: "tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200",
- titleKey: "tools.rnpath_trace.title",
- descriptionKey: "tools.rnpath_trace.description",
- },
- {
- name: "translator",
- route: { name: "translator" },
- icon: "translate",
- iconBg: "tool-card__icon bg-indigo-50 text-indigo-500 dark:bg-indigo-900/30 dark:text-indigo-200",
- titleKey: "tools.translator.title",
- descriptionKey: "tools.translator.description",
- },
- {
- name: "bots",
- route: { name: "bots" },
- icon: "robot",
- iconBg: "tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200",
- titleKey: "tools.bots.title",
- descriptionKey: "tools.bots.description",
- },
- {
- name: "propagation-nodes",
- route: { name: "propagation-nodes" },
- icon: "mailbox",
- iconBg: "tool-card__icon bg-cyan-50 text-cyan-500 dark:bg-cyan-900/30 dark:text-cyan-200",
- titleKey: "tools.propagation_nodes.title",
- descriptionKey: "tools.propagation_nodes.description",
- },
- {
- name: "forwarder",
- route: { name: "forwarder" },
- icon: "email-send-outline",
- iconBg: "tool-card__icon bg-rose-50 text-rose-500 dark:bg-rose-900/30 dark:text-rose-200",
- titleKey: "tools.forwarder.title",
- descriptionKey: "tools.forwarder.description",
- },
- {
- name: "sieve-filters",
- route: { name: "sieve-filters" },
- icon: "filter-variant",
- iconBg: "tool-card__icon bg-violet-50 text-violet-600 dark:bg-violet-900/30 dark:text-violet-200",
- titleKey: "tools.sieve_filters.title",
- descriptionKey: "tools.sieve_filters.description",
- },
- {
- name: "message-blocklist",
- route: { name: "message-blocklist" },
- icon: "shield-alert",
- iconBg: "tool-card__icon bg-rose-50 text-rose-600 dark:bg-rose-900/30 dark:text-rose-200",
- titleKey: "tools.message_blocklist.title",
- descriptionKey: "tools.message_blocklist.description",
- beta: true,
- },
- {
- name: "documentation",
- route: { name: "documentation" },
- icon: "book-open-variant",
- iconBg: "tool-card__icon bg-cyan-50 text-cyan-500 dark:bg-cyan-900/30 dark:text-cyan-200",
- titleKey: "docs.title",
- descriptionKey: "docs.subtitle",
- },
- {
- name: "repository-server",
- route: { name: "repository-server" },
- icon: "package-variant",
- iconBg: "tool-card__icon bg-sky-50 text-sky-600 dark:bg-sky-900/30 dark:text-sky-200",
- titleKey: "tools.repository_server.title",
- descriptionKey: "tools.repository_server.description",
- },
- {
- name: "micron-editor",
- route: { name: "micron-editor" },
- icon: "code-tags",
- iconBg: "tool-card__icon bg-teal-50 text-teal-500 dark:bg-teal-900/30 dark:text-teal-200",
- titleKey: "tools.micron_editor.title",
- descriptionKey: "tools.micron_editor.description",
- },
- {
- name: "reticulum-config-editor",
- route: { name: "reticulum-config-editor" },
- icon: "file-cog",
- iconBg: "tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200",
- titleKey: "tools.reticulum_config_editor.title",
- descriptionKey: "tools.reticulum_config_editor.description",
- },
- {
- name: "paper-message",
- route: { name: "paper-message" },
- icon: "qrcode",
- iconBg: "tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200",
- titleKey: "tools.paper_message.title",
- descriptionKey: "tools.paper_message.description",
- },
- {
- name: "rnode-flasher",
- route: { name: "rnode-flasher" },
- icon: null,
- image: "/rnode-flasher/reticulum_logo_512.png",
- imageClass: "w-8 h-8 rounded-full",
- imageAlt: "RNode",
- iconBg: "tool-card__icon bg-purple-50 text-purple-500 dark:bg-purple-900/30 dark:text-purple-200",
- titleKey: "tools.rnode_flasher.title",
- descriptionKey: "tools.rnode_flasher.description",
- extraAction: {
- href: "/rnode-flasher/index.html",
- target: "_blank",
- icon: "open-in-new",
- },
- },
- {
- name: "mesh-server",
- route: { name: "mesh-server" },
- icon: "server-network",
- iconBg: "tool-card__icon bg-amber-50 text-amber-500 dark:bg-amber-900/30 dark:text-amber-200",
- titleKey: "tools.mesh_server.title",
- descriptionKey: "tools.mesh_server.description",
- },
- {
- name: "rns-tunnel",
- comingSoon: true,
- icon: "tunnel",
- iconBg: "tool-card__icon bg-indigo-50 text-indigo-500 dark:bg-indigo-900/30 dark:text-indigo-200",
- titleKey: "tools.rns_tunnel.title",
- descriptionKey: "tools.rns_tunnel.description",
- },
- {
- name: "rns-filesync",
- comingSoon: true,
- icon: "folder-sync",
- iconBg: "tool-card__icon bg-emerald-50 text-emerald-500 dark:bg-emerald-900/30 dark:text-emerald-200",
- titleKey: "tools.rns_filesync.title",
- descriptionKey: "tools.rns_filesync.description",
- },
- {
- name: "debug-logs",
- route: { name: "debug-logs" },
- icon: "console",
- iconBg: "tool-card__icon bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400",
- titleKey: "debug.title",
- descriptionKey: "debug.description",
- customClass: "bg-amber-50/50 dark:bg-transparent",
- },
- ],
};
},
computed: {
+ tools() {
+ return listTools();
+ },
filteredTools() {
const toolsWithTranslations = this.tools.map((tool) => ({
...tool,
diff --git a/meshchatx/src/frontend/js/plugins/PluginHost.js b/meshchatx/src/frontend/js/plugins/PluginHost.js
new file mode 100644
index 00000000..92c16166
--- /dev/null
+++ b/meshchatx/src/frontend/js/plugins/PluginHost.js
@@ -0,0 +1,182 @@
+// SPDX-License-Identifier: 0BSD
+
+import { validatePluginManifest } from "./pluginManifest.js";
+import { registerNavItem, unregisterNavItem } from "../registries/navRegistry.js";
+import { registerTool, unregisterTool } from "../registries/toolsRegistry.js";
+import { onWsEvent, offWsEvent } from "../registries/wsEventRegistry.js";
+
+/** @typedef {import('./pluginManifest.js').PluginManifest} PluginManifest */
+
+export class PluginHost {
+ constructor() {
+ /** @type {Map<string, { worker: Worker, cleanup: Array<() => void>, manifest: PluginManifest }>} */
+ this.instances = new Map();
+ }
+
+ async loadEnabledPlugins(apiClient, labels = {}) {
+ const response = await apiClient.get("/api/v1/plugins");
+ const plugins = response.data?.plugins || [];
+ for (const plugin of plugins) {
+ if (!plugin.enabled) {
+ continue;
+ }
+ await this.loadPlugin(plugin, apiClient, labels);
+ }
+ }
+
+ /**
+ * @param {Record<string, unknown>} plugin
+ * @param {ReturnType<import('../apiClient.js').createApiClient>} apiClient
+ */
+ async loadPlugin(plugin, apiClient, labels = {}) {
+ const pluginId = plugin.id;
+ if (this.instances.has(pluginId)) {
+ return;
+ }
+ const manifest = validatePluginManifest(plugin.manifest);
+ if (!manifest.frontend) {
+ return;
+ }
+ const assetUrl = `/api/v1/plugins/${encodeURIComponent(pluginId)}/asset/${manifest.frontend.entry}`;
+ const sourceResponse = await apiClient.get(assetUrl, { responseType: "text" });
+ const source = typeof sourceResponse.data === "string" ? sourceResponse.data : String(sourceResponse.data ?? "");
+ const worker = new Worker(new URL("./pluginWorker.js", import.meta.url), { type: "module" });
+ const cleanup = [];
+
+ worker.onmessage = (event) => {
+ this.handleWorkerMessage(pluginId, event.data);
+ };
+ worker.onerror = () => {
+ this.unloadPlugin(pluginId);
+ };
+
+ worker.postMessage({
+ type: "init",
+ pluginId,
+ permissions: manifest.permissions || {},
+ source,
+ labels,
+ });
+
+ cleanup.push(...this.registerContributions(pluginId, manifest));
+ if ((manifest.permissions?.hooks || []).length > 0) {
+ const eventHandler = (payload) => {
+ if (payload?.plugin_id !== pluginId) {
+ return;
+ }
+ worker.postMessage({
+ type: "event",
+ event: payload?.event,
+ payload: payload?.payload,
+ });
+ };
+ onWsEvent("plugin.event", eventHandler);
+ cleanup.push(() => offWsEvent("plugin.event", eventHandler));
+ }
+
+ const requestHandler = async (message) => {
+ if (!message || message.type !== "request") {
+ return;
+ }
+ try {
+ let result;
+ if (message.kind === "invoke") {
+ const response = await apiClient.post(`/api/v1/plugins/${encodeURIComponent(pluginId)}/invoke`, {
+ method: message.payload.method,
+ args: message.payload.args,
+ });
+ result = response.data?.result;
+ } else if (message.kind === "manager") {
+ const response = await apiClient.post(`/api/v1/plugins/${encodeURIComponent(pluginId)}/invoke`, {
+ method: "callManager",
+ args: message.payload,
+ });
+ result = response.data?.result;
+ }
+ worker.postMessage({ requestId: message.requestId, result });
+ } catch (error) {
+ worker.postMessage({
+ requestId: message.requestId,
+ error: error?.message || String(error),
+ });
+ }
+ };
+ worker.addEventListener("message", (event) => {
+ void requestHandler(event.data);
+ });
+
+ this.instances.set(pluginId, { worker, cleanup, manifest });
+ }
+
+ /**
+ * @param {string} pluginId
+ * @param {PluginManifest} manifest
+ */
+ registerContributions(pluginId, manifest) {
+ const cleanup = [];
+ const contributes = manifest.contributes || {};
+ for (const item of contributes.navItems || []) {
+ registerNavItem({ ...item, pluginId });
+ cleanup.push(() => unregisterNavItem(item.id));
+ }
+ for (const item of contributes.toolsPageEntries || []) {
+ registerTool({ ...item, pluginId });
+ cleanup.push(() => unregisterTool(item.name));
+ }
+ return cleanup;
+ }
+
+ /**
+ * @param {string} pluginId
+ * @param {unknown} message
+ */
+ handleWorkerMessage(pluginId, message) {
+ if (!message || typeof message !== "object") {
+ return;
+ }
+ if (message.type === "ui") {
+ window.dispatchEvent(
+ new CustomEvent("meshchatx-plugin-ui", {
+ detail: { pluginId, descriptor: message.descriptor },
+ })
+ );
+ }
+ if (message.type === "error") {
+ window.dispatchEvent(
+ new CustomEvent("meshchatx-plugin-error", {
+ detail: { pluginId, message: message.message },
+ })
+ );
+ }
+ }
+
+ unloadPlugin(pluginId) {
+ const instance = this.instances.get(pluginId);
+ if (!instance) {
+ return;
+ }
+ instance.worker.terminate();
+ for (const fn of instance.cleanup) {
+ fn();
+ }
+ this.instances.delete(pluginId);
+ }
+
+ postAction(pluginId, actionId) {
+ const instance = this.instances.get(pluginId);
+ if (!instance) {
+ return;
+ }
+ instance.worker.postMessage({ type: "action", actionId });
+ }
+
+ postInput(pluginId, id, value) {
+ const instance = this.instances.get(pluginId);
+ if (!instance) {
+ return;
+ }
+ instance.worker.postMessage({ type: "input", id, value });
+ }
+}
+
+export const pluginHost = new PluginHost();
diff --git a/meshchatx/src/frontend/js/plugins/pluginManifest.js b/meshchatx/src/frontend/js/plugins/pluginManifest.js
new file mode 100644
index 00000000..a8c46364
--- /dev/null
+++ b/meshchatx/src/frontend/js/plugins/pluginManifest.js
@@ -0,0 +1,87 @@
+// SPDX-License-Identifier: 0BSD
+
+const SUPPORTED_API_VERSION = 1;
+
+/**
+ * @typedef {Object} PluginManifest
+ * @property {string} id
+ * @property {string} version
+ * @property {string | number} apiVersion
+ * @property {string} [name]
+ * @property {string} [description]
+ * @property {{ entry: string, type: 'js' | 'wasm' }} [frontend]
+ * @property {{ entry: string, type: 'wasm' }} [backend]
+ * @property {Object} [contributes]
+ * @property {Object} [permissions]
+ */
+
+/**
+ * @param {unknown} manifest
+ * @returns {PluginManifest}
+ */
+export function validatePluginManifest(manifest) {
+ if (!manifest || typeof manifest !== "object") {
+ throw new Error("Plugin manifest must be an object");
+ }
+ const record = /** @type {Record<string, unknown>} */ (manifest);
+ if (typeof record.id !== "string" || !record.id.trim()) {
+ throw new Error("Plugin manifest requires a non-empty id");
+ }
+ if (typeof record.version !== "string" || !record.version.trim()) {
+ throw new Error("Plugin manifest requires a version");
+ }
+ const apiVersion = Number(record.apiVersion);
+ if (!Number.isFinite(apiVersion) || apiVersion !== SUPPORTED_API_VERSION) {
+ throw new Error(`Plugin apiVersion must be ${SUPPORTED_API_VERSION}`);
+ }
+ if (record.frontend != null) {
+ const frontend = /** @type {Record<string, unknown>} */ (record.frontend);
+ if (typeof frontend.entry !== "string" || !frontend.entry.trim()) {
+ throw new Error("Plugin frontend.entry is required when frontend is set");
+ }
+ if (frontend.type !== "js" && frontend.type !== "wasm") {
+ throw new Error("Plugin frontend.type must be js or wasm");
+ }
+ }
+ if (record.backend != null) {
+ const backend = /** @type {Record<string, unknown>} */ (record.backend);
+ if (typeof backend.entry !== "string" || !backend.entry.trim()) {
+ throw new Error("Plugin backend.entry is required when backend is set");
+ }
+ if (backend.type !== "wasm") {
+ throw new Error("Plugin backend.type must be wasm");
+ }
+ }
+ const permissions = record.permissions ?? {};
+ if (permissions && typeof permissions !== "object") {
+ throw new Error("Plugin permissions must be an object");
+ }
+ return /** @type {PluginManifest} */ (manifest);
+}
+
+/**
+ * @param {PluginManifest} manifest
+ * @returns {string[]}
+ */
+export function manifestPermissionSummary(manifest) {
+ const permissions = manifest.permissions ?? {};
+ const lines = [];
+ if (Array.isArray(permissions.hooks) && permissions.hooks.length > 0) {
+ lines.push(`Hooks: ${permissions.hooks.join(", ")}`);
+ }
+ if (Array.isArray(permissions.managers) && permissions.managers.length > 0) {
+ lines.push(`Managers: ${permissions.managers.join(", ")}`);
+ }
+ if (permissions.storage === "isolated") {
+ lines.push("Isolated plugin storage");
+ }
+ if (permissions.network && permissions.network !== "none") {
+ lines.push(`Network: ${permissions.network}`);
+ }
+ if (lines.length === 0) {
+ lines.push("No elevated permissions");
+ }
+ return lines;
+}
+
+export { SUPPORTED_API_VERSION };
diff --git a/meshchatx/src/frontend/js/plugins/pluginWorker.js b/meshchatx/src/frontend/js/plugins/pluginWorker.js
new file mode 100644
index 00000000..ce70c13b
--- /dev/null
+++ b/meshchatx/src/frontend/js/plugins/pluginWorker.js
@@ -0,0 +1,114 @@
+// SPDX-License-Identifier: 0BSD
+
+/**
+ * @param {MessageEvent} event
+ * @param {(message: unknown) => void} post
+ */
+function handleWorkerMessage(event, post) {
+ const message = event.data;
+ if (!message || typeof message !== "object") {
+ return;
+ }
+
+ if (message.type === "init") {
+ const state = {
+ pluginId: message.pluginId,
+ permissions: message.permissions || {},
+ ui: null,
+ inputValues: {},
+ actionHandler: null,
+ eventHandlers: new Map(),
+ };
+
+ const api = {
+ t(key) {
+ return message.labels?.[key] || key;
+ },
+ async invoke(method, args = {}) {
+ if (method === "readPaths") {
+ return postRequest("manager", {
+ capability: "destinationPath.read",
+ args,
+ });
+ }
+ return postRequest("invoke", { method, args });
+ },
+ setUi(descriptor) {
+ state.ui = descriptor;
+ post({ type: "ui", descriptor });
+ },
+ onAction(handler) {
+ state.actionHandler = handler;
+ },
+ onEvent(eventName, handler) {
+ state.eventHandlers.set(eventName, handler);
+ },
+ getInputValue(id) {
+ return state.inputValues[id] ?? "";
+ },
+ };
+
+ function postRequest(kind, payload) {
+ return new Promise((resolve, reject) => {
+ const requestId = `${Date.now()}-${Math.random()}`;
+ const onReply = (replyEvent) => {
+ const reply = replyEvent.data;
+ if (!reply || reply.requestId !== requestId) {
+ return;
+ }
+ self.removeEventListener("message", onReply);
+ if (reply.error) {
+ reject(new Error(reply.error));
+ return;
+ }
+ resolve(reply.result);
+ };
+ self.addEventListener("message", onReply);
+ post({ type: "request", requestId, kind, payload });
+ });
+ }
+
+ const source = `${message.source}\n//# sourceURL=plugin-${message.pluginId}.js`;
+ const blob = new Blob([source], { type: "text/javascript" });
+ const blobUrl = URL.createObjectURL(blob);
+ import(/* @vite-ignore */ blobUrl)
+ .then((module) => {
+ URL.revokeObjectURL(blobUrl);
+ if (typeof module.activate === "function") {
+ return module.activate(api);
+ }
+ throw new Error("Plugin must export activate(api)");
+ })
+ .catch((error) => {
+ URL.revokeObjectURL(blobUrl);
+ post({ type: "error", message: error.message || String(error) });
+ });
+
+ self.onmessage = (nextEvent) => {
+ const next = nextEvent.data;
+ if (!next || typeof next !== "object") {
+ return;
+ }
+ if (next.type === "action") {
+ if (typeof state.actionHandler === "function") {
+ void state.actionHandler(next.actionId);
+ }
+ return;
+ }
+ if (next.type === "input") {
+ state.inputValues[next.id] = next.value;
+ return;
+ }
+ if (next.type === "event") {
+ const handler = state.eventHandlers.get(next.event);
+ if (typeof handler === "function") {
+ void handler(next.payload);
+ }
+ }
+ };
+ }
+}
+
+self.onmessage = (event) => {
+ handleWorkerMessage(event, (payload) => self.postMessage(payload));
+};
diff --git a/meshchatx/src/frontend/js/registries/commandRegistry.js b/meshchatx/src/frontend/js/registries/commandRegistry.js
new file mode 100644
index 00000000..725bdf1f
--- /dev/null
+++ b/meshchatx/src/frontend/js/registries/commandRegistry.js
@@ -0,0 +1,29 @@
+// SPDX-License-Identifier: 0BSD
+
+import { createRegistry } from "./registryCore.js";
+
+/** @typedef {import('./coreCommandEntries.js').CommandEntry} CommandEntry */
+
+/** @type {import('./registryCore.js').Registry<CommandEntry>} */
+export const commandRegistry = createRegistry("commandRegistry");
+
+/**
+ * @param {CommandEntry} entry
+ */
+export function registerCommand(entry) {
+ commandRegistry.register(entry);
+}
+
+/**
+ * @param {string} id
+ */
+export function unregisterCommand(id) {
+ commandRegistry.unregister(id);
+}
+
+/**
+ * @returns {CommandEntry[]}
+ */
+export function listCommands() {
+ return commandRegistry.list();
+}
diff --git a/meshchatx/src/frontend/js/registries/coreCommandEntries.js b/meshchatx/src/frontend/js/registries/coreCommandEntries.js
new file mode 100644
index 00000000..1d7725f5
--- /dev/null
+++ b/meshchatx/src/frontend/js/registries/coreCommandEntries.js
@@ -0,0 +1,209 @@
+// SPDX-License-Identifier: 0BSD
+
+/**
+ * @typedef {Object} CommandEntry
+ * @property {string} id
+ * @property {string} title
+ * @property {string} description
+ * @property {string} icon
+ * @property {'navigation' | 'action'} type
+ * @property {{ name: string }} [route]
+ * @property {'sync' | 'compose' | 'show-tutorial' | 'show-changelog'} [action]
+ * @property {string | null} [pluginId]
+ */
+
+/** @type {CommandEntry[]} */
+export const CORE_COMMAND_ENTRIES = [
+ {
+ id: "nav-messages",
+ title: "nav_messages",
+ description: "nav_messages_desc",
+ icon: "message-text",
+ type: "navigation",
+ route: { name: "messages" },
+ },
+ {
+ id: "nav-call",
+ title: "nav_call",
+ description: "nav_call_desc",
+ icon: "phone",
+ type: "navigation",
+ route: { name: "call" },
+ },
+ {
+ id: "nav-nomad",
+ title: "nav_nomad",
+ description: "nav_nomad_desc",
+ icon: "earth",
+ type: "navigation",
+ route: { name: "nomadnetwork" },
+ },
+ {
+ id: "nav-map",
+ title: "nav_map",
+ description: "nav_map_desc",
+ icon: "map",
+ type: "navigation",
+ route: { name: "map" },
+ },
+ {
+ id: "nav-paper",
+ title: "nav_paper",
+ description: "nav_paper_desc",
+ icon: "qrcode",
+ type: "navigation",
+ route: { name: "paper-message" },
+ },
+ {
+ id: "nav-settings",
+ title: "nav_settings",
+ description: "nav_settings_desc",
+ icon: "cog",
+ type: "navigation",
+ route: { name: "settings" },
+ },
+ {
+ id: "nav-ping",
+ title: "nav_ping",
+ description: "nav_ping_desc",
+ icon: "radar",
+ type: "navigation",
+ route: { name: "ping" },
+ },
+ {
+ id: "nav-rnprobe",
+ title: "nav_rnprobe",
+ description: "nav_rnprobe_desc",
+ icon: "radar",
+ type: "navigation",
+ route: { name: "rnprobe" },
+ },
+ {
+ id: "nav-rncp",
+ title: "nav_rncp",
+ description: "nav_rncp_desc",
+ icon: "swap-horizontal",
+ type: "navigation",
+ route: { name: "rncp" },
+ },
+ {
+ id: "nav-rnstatus",
+ title: "nav_rnstatus",
+ description: "nav_rnstatus_desc",
+ icon: "chart-line",
+ type: "navigation",
+ route: { name: "rnstatus" },
+ },
+ {
+ id: "nav-rnpath",
+ title: "nav_rnpath",
+ description: "nav_rnpath_desc",
+ icon: "route",
+ type: "navigation",
+ route: { name: "rnpath" },
+ },
+ {
+ id: "nav-rnpath-trace",
+ title: "nav_rnpath_trace",
+ description: "nav_rnpath_trace_desc",
+ icon: "map-marker-path",
+ type: "navigation",
+ route: { name: "rnpath-trace" },
+ },
+ {
+ id: "nav-translator",
+ title: "nav_translator",
+ description: "nav_translator_desc",
+ icon: "translate",
+ type: "navigation",
+ route: { name: "translator" },
+ },
+ {
+ id: "nav-forwarder",
+ title: "nav_forwarder",
+ description: "nav_forwarder_desc",
+ icon: "email-send-outline",
+ type: "navigation",
+ route: { name: "forwarder" },
+ },
+ {
+ id: "nav-documentation",
+ title: "nav_documentation",
+ description: "nav_documentation_desc",
+ icon: "book-open-variant",
+ type: "navigation",
+ route: { name: "documentation" },
+ },
+ {
+ id: "nav-repository-server",
+ title: "nav_repository_server",
+ description: "nav_repository_server_desc",
+ icon: "package-variant",
+ type: "navigation",
+ route: { name: "repository-server" },
+ },
+ {
+ id: "nav-micron-editor",
+ title: "nav_micron_editor",
+ description: "nav_micron_editor_desc",
+ icon: "code-tags",
+ type: "navigation",
+ route: { name: "micron-editor" },
+ },
+ {
+ id: "nav-reticulum-config-editor",
+ title: "nav_reticulum_config_editor",
+ description: "nav_reticulum_config_editor_desc",
+ icon: "file-cog",
+ type: "navigation",
+ route: { name: "reticulum-config-editor" },
+ },
+ {
+ id: "nav-rnode-flasher",
+ title: "nav_rnode_flasher",
+ description: "nav_rnode_flasher_desc",
+ icon: "flash",
+ type: "navigation",
+ route: { name: "rnode-flasher" },
+ },
+ {
+ id: "nav-debug-logs",
+ title: "nav_debug_logs",
+ description: "nav_debug_logs_desc",
+ icon: "console",
+ type: "navigation",
+ route: { name: "debug-logs" },
+ },
+ {
+ id: "action-sync",
+ title: "action_sync",
+ description: "action_sync_desc",
+ icon: "refresh",
+ type: "action",
+ action: "sync",
+ },
+ {
+ id: "action-compose",
+ title: "action_compose",
+ description: "action_compose_desc",
+ icon: "email-plus",
+ type: "action",
+ action: "compose",
+ },
+ {
+ id: "action-getting-started",
+ title: "action_getting_started",
+ description: "action_getting_started_desc",
+ icon: "help-circle",
+ type: "action",
+ action: "show-tutorial",
+ },
+ {
+ id: "action-changelog",
+ title: "action_changelog",
+ description: "action_changelog_desc",
+ icon: "history",
+ type: "action",
+ action: "show-changelog",
+ },
+];
diff --git a/meshchatx/src/frontend/js/registries/coreNavEntries.js b/meshchatx/src/frontend/js/registries/coreNavEntries.js
new file mode 100644
index 00000000..8bc57498
--- /dev/null
+++ b/meshchatx/src/frontend/js/registries/coreNavEntries.js
@@ -0,0 +1,105 @@
+// SPDX-License-Identifier: 0BSD
+
+/** @typedef {'unreadConversationsCount' | 'relayChatUnreadCount'} NavBadgeSource */
+
+/**
+ * @typedef {Object} NavEntry
+ * @property {string} id
+ * @property {{ name: string }} route
+ * @property {string} icon
+ * @property {string} labelKey
+ * @property {{ source: NavBadgeSource, pill?: boolean, cap?: number } | null} [badge]
+ * @property {'rrcEnabled' | null} [visibleWhen]
+ * @property {string | null} [pluginId]
+ */
+
+/** @type {NavEntry[]} */
+export const CORE_NAV_ENTRIES = [
+ {
+ id: "messages",
+ route: { name: "messages" },
+ icon: "message-text",
+ labelKey: "app.messages",
+ badge: { source: "unreadConversationsCount" },
+ },
+ {
+ id: "call",
+ route: { name: "call" },
+ icon: "phone",
+ labelKey: "app.audio_calls",
+ },
+ {
+ id: "contacts",
+ route: { name: "contacts" },
+ icon: "account-multiple",
+ labelKey: "app.contacts",
+ },
+ {
+ id: "relay-chat",
+ route: { name: "relay-chat" },
+ icon: "forum",
+ labelKey: "app.relay_chat",
+ badge: { source: "relayChatUnreadCount", pill: true, cap: 1000 },
+ visibleWhen: "rrcEnabled",
+ },
+ {
+ id: "nomadnetwork",
+ route: { name: "nomadnetwork" },
+ icon: "earth",
+ labelKey: "app.nomad_network",
+ },
+ {
+ id: "map",
+ route: { name: "map" },
+ icon: "map",
+ labelKey: "app.map",
+ },
+ {
+ id: "archives",
+ route: { name: "archives" },
+ icon: "archive",
+ labelKey: "app.archives",
+ },
+ {
+ id: "tools",
+ route: { name: "tools" },
+ icon: "wrench",
+ labelKey: "app.tools",
+ },
+ {
+ id: "interfaces",
+ route: { name: "interfaces" },
+ icon: "router",
+ labelKey: "app.interfaces",
+ },
+ {
+ id: "network-visualiser",
+ route: { name: "network-visualiser" },
+ icon: "hub",
+ labelKey: "app.network_visualiser",
+ },
+ {
+ id: "blocked",
+ route: { name: "blocked" },
+ icon: "gavel",
+ labelKey: "banishment.title",
+ },
+ {
+ id: "settings",
+ route: { name: "settings" },
+ icon: "cog",
+ labelKey: "app.settings",
+ },
+ {
+ id: "identities",
+ route: { name: "identities" },
+ icon: "badge-account",
+ labelKey: "app.identities",
+ },
+ {
+ id: "about",
+ route: { name: "about" },
+ icon: "information",
+ labelKey: "app.about",
+ },
+];
diff --git a/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js b/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js
new file mode 100644
index 00000000..22d5a16c
--- /dev/null
+++ b/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js
@@ -0,0 +1,324 @@
+// SPDX-License-Identifier: 0BSD
+
+/** @type {Record<string, string[]>} */
+export const CORE_SETTINGS_SECTION_KEYWORDS = {
+ telephony: [
+ "Telephony",
+ "Telephone",
+ "LXST",
+ "Enable Telephone",
+ "voice",
+ "calling",
+ "call",
+ "mesh network",
+ ],
+ strangerProtection: [
+ "Security",
+ "app.stranger_protection",
+ "app.stranger_protection_description",
+ "app.block_stranger_attachments",
+ "app.block_stranger_attachments_description",
+ "app.block_all_from_strangers",
+ "app.block_all_from_strangers_description",
+ "app.show_unknown_contact_banner",
+ "app.show_unknown_contact_banner_description",
+ "app.warn_on_stranger_links",
+ "app.warn_on_stranger_links_description",
+ "stranger",
+ "attachments",
+ "trust",
+ "block",
+ "banner",
+ "unknown",
+ "contact",
+ "links",
+ ],
+ visualiser: [
+ "Visualiser",
+ "Network Visualiser",
+ "visualiser",
+ "graph",
+ "mesh",
+ "visualiser.show_disabled_interfaces",
+ "visualiser.show_discovered_interfaces",
+ "offline",
+ "discovered",
+ ],
+ banishment: [
+ "Visuals",
+ "app.banishment",
+ "app.banishment_description",
+ "app.banished_effect_enabled",
+ "app.banished_effect_description",
+ "app.banished_text_label",
+ "app.banished_text_description",
+ "app.banished_color_label",
+ "app.banished_color_description",
+ ],
+ stickers: [
+ "Stickers",
+ "stickers.settings_title",
+ "stickers.settings_description",
+ "stickers.export",
+ "stickers.import",
+ "stickers.replace_duplicates",
+ "sticker_packs.section_title",
+ "sticker_packs.create",
+ "sticker_packs.install_from_file",
+ "sticker_packs.open_editor",
+ ],
+ gifs: [
+ "GIFs",
+ "gifs.settings_title",
+ "gifs.settings_description",
+ "gifs.export",
+ "gifs.import",
+ "gifs.replace_duplicates",
+ ],
+ maintenance: [
+ "Maintenance",
+ "maintenance.title",
+ "maintenance.description",
+ "maintenance.clear_messages",
+ "maintenance.clear_messages_desc",
+ "maintenance.clear_announces",
+ "maintenance.clear_announces_desc",
+ "maintenance.clear_nomadnet_favs",
+ "maintenance.clear_nomadnet_favs_desc",
+ "maintenance.clear_lxmf_icons",
+ "maintenance.clear_lxmf_icons_desc",
+ "maintenance.clear_stickers",
+ "maintenance.clear_stickers_desc",
+ "maintenance.clear_gifs",
+ "maintenance.clear_gifs_desc",
+ "maintenance.clear_archives",
+ "maintenance.clear_archives_desc",
+ "maintenance.clear_reticulum_docs",
+ "maintenance.clear_reticulum_docs_desc",
+ "maintenance.clear_path_table",
+ "maintenance.clear_path_table_desc",
+ "maintenance.export_messages",
+ "maintenance.export_messages_desc",
+ "maintenance.import_messages",
+ "maintenance.import_messages_desc",
+ "maintenance.export_nomadnet_favourites",
+ "maintenance.import_nomadnet_favourites",
+ "Automatic Backup Limit",
+ "Export Folders",
+ "Import Folders",
+ ],
+ desktop: [
+ "Desktop",
+ "App Behaviour",
+ "app.desktop_open_calls_in_separate_window",
+ "app.desktop_open_calls_in_separate_window_description",
+ "app.desktop_hardware_acceleration_enabled",
+ "app.desktop_hardware_acceleration_enabled_description",
+ ],
+ android: [
+ "Android",
+ "APK",
+ "Bluetooth",
+ "Nearby Share",
+ "settings.share_apk_heading",
+ "settings.share_apk_desc",
+ "settings.share_apk",
+ "settings.share_apk_short_hint",
+ ],
+ archiver: ["Browsing", "Page Archiver", "archiver", "archive", "versions", "storage", "flush"],
+ nomadRenderer: [
+ "NomadNet",
+ "NomadNet browser renderer",
+ "micron-parser-go",
+ "WASM",
+ "SHASUMS",
+ "micron wasm update",
+ "browser",
+ "renderer",
+ "markdown",
+ "HTML",
+ "plaintext",
+ "micron-parser",
+ "index.mu",
+ "index.html",
+ "default page",
+ "settings.nomad_micron_default_engine_title",
+ "settings.nomad_micron_default_engine_desc",
+ ],
+ crawler: ["Discovery", "Smart Crawler", "crawler", "crawl", "retries", "delay", "concurrent"],
+ csp: [
+ "Security",
+ "app.csp_settings",
+ "app.csp_description",
+ "app.csp_extra_connect_src",
+ "app.csp_extra_img_src",
+ "app.csp_extra_frame_src",
+ "app.csp_extra_script_src",
+ "app.csp_extra_style_src",
+ "CSP",
+ "Content Security Policy",
+ ],
+ appearance: [
+ "Personalise",
+ "app.appearance",
+ "app.appearance_description",
+ "app.theme",
+ "app.light_theme",
+ "app.dark_theme",
+ "app.messages_sidebar_position",
+ "app.messages_sidebar_position_left",
+ "app.messages_sidebar_position_right",
+ "app.messages_multi_pane_enabled",
+ "app.messages_multi_pane_enabled_description",
+ "app.nomad_tabs_enabled",
+ "app.nomad_tabs_enabled_description",
+ "app.ui_transparency",
+ "app.ui_glass_enabled",
+ "app.reset_appearance_defaults",
+ "Message Font Size",
+ "Icon Size",
+ "Message Bubbles",
+ "Waiting Color",
+ "app.live_preview",
+ "app.realtime",
+ ],
+ language: [
+ "i18n",
+ "app.language",
+ "app.select_language",
+ "English",
+ "Deutsch",
+ "Italiano",
+ "Русский",
+ "Nederlands",
+ "Français",
+ "Español",
+ "中文",
+ ],
+ networkSecurity: [
+ "RNS Security",
+ "Network Security",
+ "app.blackhole_integration_enabled",
+ "app.blackhole_integration_description",
+ "app.announce_limits",
+ "app.announce_store_heading",
+ "app.announce_store_lxmf",
+ "app.announce_store_lxst",
+ "app.announce_store_nomad",
+ "app.announce_store_prop",
+ "app.announce_limit_lxmf",
+ "app.announce_limit_nomadnet",
+ "app.announce_limit_prop",
+ "app.announce_max_stored_heading",
+ "app.announce_fetch_limit_heading",
+ "app.announce_search_max_fetch",
+ "app.discovered_interfaces_max_return",
+ ],
+ transport: [
+ "Reticulum",
+ "app.transport_mode",
+ "app.transport_description",
+ "app.enable_transport_mode",
+ "app.transport_toggle_description",
+ ],
+ interfaces: [
+ "Adapters",
+ "app.interfaces",
+ "app.show_community_interfaces",
+ "app.community_interfaces_description",
+ ],
+ blocked: ["Privacy", "Banished", "Manage Banished users and nodes"],
+ auth: ["Security", "Authentication", "password", "Protect your instance with a password"],
+ webExposure: [
+ "Security",
+ "Network exposure",
+ "app.web_exposure_title",
+ "app.web_exposure_description",
+ "app.web_listen_address",
+ "app.web_ui_ip_allowlist",
+ "app.web_exposure_warning_title",
+ "app.landlock_status",
+ "allowlist",
+ "firewall",
+ "VPN",
+ "bind",
+ "localhost",
+ ],
+ infrastructure: ["Infrastructure", "Sources & Mirroring", "gitea", "documentation", "download", "urls"],
+ messages: [
+ "app.lxmf_settings_eyebrow",
+ "app.messages",
+ "app.messages_description",
+ "app.auto_resend_title",
+ "app.auto_resend_description",
+ "app.retry_attachments_title",
+ "app.retry_attachments_description",
+ "app.auto_fallback_title",
+ "app.auto_fallback_description",
+ "app.inbound_stamp_cost",
+ "app.inbound_stamp_description",
+ "app.inbound_stamps_required_title",
+ "app.inbound_stamps_required_description",
+ "app.flood_protection",
+ "app.flood_protection_description",
+ "app.flood_protection_enabled",
+ "app.flood_threshold",
+ "app.flood_max_stamp_cost",
+ "app.flood_cooldown",
+ ],
+ propagation: [
+ "LXMF",
+ "app.incoming_message_size",
+ "app.incoming_message_size_description",
+ "app.propagation_nodes",
+ "app.propagation_nodes_description",
+ "app.browse_nodes",
+ "app.run_local_node",
+ "app.run_local_node_description",
+ "app.auto_select_node",
+ "app.auto_select_node_description",
+ "app.auto_select_using_label",
+ "app.auto_select_pending",
+ "app.preferred_propagation_node",
+ "app.auto_sync_interval",
+ "app.propagation_stamp_cost",
+ "app.propagation_stamp_description",
+ ],
+ location: [
+ "app.location",
+ "app.location_manage_desc",
+ "app.location_source",
+ "Map",
+ "Location",
+ "GPS",
+ "manual",
+ "latitude",
+ "longitude",
+ "altitude",
+ ],
+ privacyData: [
+ "app.privacy_data_title",
+ "app.privacy_data_description",
+ "app.privacy_mode_enabled",
+ "app.privacy_mode_description",
+ "app.local_message_auto_delete_title",
+ "app.local_message_auto_delete_description",
+ "app.local_message_auto_delete_age",
+ "app.telemetry_enabled",
+ "app.telemetry_description",
+ "app.telemetry_trusted_peers",
+ "ephemeral",
+ "retention",
+ "Privacy",
+ ],
+ shortcuts: ["Keyboard Shortcuts", "actions", "workflow"],
+ plugins: [
+ "Plugins",
+ "plugins.settings.title",
+ "plugins.settings.description",
+ "extensions",
+ "install",
+ "enable",
+ "disable",
+ ],
+};
diff --git a/meshchatx/src/frontend/js/registries/coreToolsEntries.js b/meshchatx/src/frontend/js/registries/coreToolsEntries.js
new file mode 100644
index 00000000..13e5bf47
--- /dev/null
+++ b/meshchatx/src/frontend/js/registries/coreToolsEntries.js
@@ -0,0 +1,219 @@
+// SPDX-License-Identifier: 0BSD
+
+/**
+ * @typedef {Object} ToolEntry
+ * @property {string} name
+ * @property {{ name: string } | null} [route]
+ * @property {string | null} [icon]
+ * @property {string} iconBg
+ * @property {string} titleKey
+ * @property {string} descriptionKey
+ * @property {string} [title]
+ * @property {string} [description]
+ * @property {boolean} [beta]
+ * @property {boolean} [comingSoon]
+ * @property {string} [customClass]
+ * @property {string} [image]
+ * @property {string} [imageClass]
+ * @property {string} [imageAlt]
+ * @property {{ href: string, target: string, icon: string }} [extraAction]
+ * @property {string | null} [pluginId]
+ */
+
+/** @type {ToolEntry[]} */
+export const CORE_TOOLS_ENTRIES = [
+ {
+ name: "ping",
+ route: { name: "ping" },
+ icon: "radar",
+ iconBg: "tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200",
+ titleKey: "tools.ping.title",
+ descriptionKey: "tools.ping.description",
+ },
+ {
+ name: "rnprobe",
+ route: { name: "rnprobe" },
+ icon: "radar",
+ iconBg: "tool-card__icon bg-purple-50 text-purple-500 dark:bg-purple-900/30 dark:text-purple-200",
+ titleKey: "tools.rnprobe.title",
+ descriptionKey: "tools.rnprobe.description",
+ },
+ {
+ name: "rncp",
+ route: { name: "rncp" },
+ icon: "swap-horizontal",
+ iconBg: "tool-card__icon bg-green-50 text-green-500 dark:bg-green-900/30 dark:text-green-200",
+ titleKey: "tools.rncp.title",
+ descriptionKey: "tools.rncp.description",
+ },
+ {
+ name: "rnsh",
+ route: { name: "rnsh" },
+ icon: "console-network-outline",
+ iconBg: "tool-card__icon bg-indigo-50 text-indigo-600 dark:bg-indigo-900/30 dark:text-indigo-200",
+ titleKey: "tools.rnsh.title",
+ descriptionKey: "tools.rnsh.description",
+ },
+ {
+ name: "rnstatus",
+ route: { name: "rnstatus" },
+ icon: "chart-line",
+ iconBg: "tool-card__icon bg-orange-50 text-orange-500 dark:bg-orange-900/30 dark:text-orange-200",
+ titleKey: "tools.rnstatus.title",
+ descriptionKey: "tools.rnstatus.description",
+ },
+ {
+ name: "rnpath",
+ route: { name: "rnpath" },
+ icon: "route",
+ iconBg: "tool-card__icon bg-indigo-50 text-indigo-500 dark:bg-indigo-900/30 dark:text-indigo-200",
+ titleKey: "tools.rnpath.title",
+ descriptionKey: "tools.rnpath.description",
+ },
+ {
+ name: "rnpath-trace",
+ route: { name: "rnpath-trace" },
+ icon: "map-marker-path",
+ iconBg: "tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200",
+ titleKey: "tools.rnpath_trace.title",
+ descriptionKey: "tools.rnpath_trace.description",
+ },
+ {
+ name: "translator",
+ route: { name: "translator" },
+ icon: "translate",
+ iconBg: "tool-card__icon bg-indigo-50 text-indigo-500 dark:bg-indigo-900/30 dark:text-indigo-200",
+ titleKey: "tools.translator.title",
+ descriptionKey: "tools.translator.description",
+ },
+ {
+ name: "bots",
+ route: { name: "bots" },
+ icon: "robot",
+ iconBg: "tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200",
+ titleKey: "tools.bots.title",
+ descriptionKey: "tools.bots.description",
+ },
+ {
+ name: "propagation-nodes",
+ route: { name: "propagation-nodes" },
+ icon: "mailbox",
+ iconBg: "tool-card__icon bg-cyan-50 text-cyan-500 dark:bg-cyan-900/30 dark:text-cyan-200",
+ titleKey: "tools.propagation_nodes.title",
+ descriptionKey: "tools.propagation_nodes.description",
+ },
+ {
+ name: "forwarder",
+ route: { name: "forwarder" },
+ icon: "email-send-outline",
+ iconBg: "tool-card__icon bg-rose-50 text-rose-500 dark:bg-rose-900/30 dark:text-rose-200",
+ titleKey: "tools.forwarder.title",
+ descriptionKey: "tools.forwarder.description",
+ },
+ {
+ name: "sieve-filters",
+ route: { name: "sieve-filters" },
+ icon: "filter-variant",
+ iconBg: "tool-card__icon bg-violet-50 text-violet-600 dark:bg-violet-900/30 dark:text-violet-200",
+ titleKey: "tools.sieve_filters.title",
+ descriptionKey: "tools.sieve_filters.description",
+ },
+ {
+ name: "message-blocklist",
+ route: { name: "message-blocklist" },
+ icon: "shield-alert",
+ iconBg: "tool-card__icon bg-rose-50 text-rose-600 dark:bg-rose-900/30 dark:text-rose-200",
+ titleKey: "tools.message_blocklist.title",
+ descriptionKey: "tools.message_blocklist.description",
+ beta: true,
+ },
+ {
+ name: "documentation",
+ route: { name: "documentation" },
+ icon: "book-open-variant",
+ iconBg: "tool-card__icon bg-cyan-50 text-cyan-500 dark:bg-cyan-900/30 dark:text-cyan-200",
+ titleKey: "docs.title",
+ descriptionKey: "docs.subtitle",
+ },
+ {
+ name: "repository-server",
+ route: { name: "repository-server" },
+ icon: "package-variant",
+ iconBg: "tool-card__icon bg-sky-50 text-sky-600 dark:bg-sky-900/30 dark:text-sky-200",
+ titleKey: "tools.repository_server.title",
+ descriptionKey: "tools.repository_server.description",
+ },
+ {
+ name: "micron-editor",
+ route: { name: "micron-editor" },
+ icon: "code-tags",
+ iconBg: "tool-card__icon bg-teal-50 text-teal-500 dark:bg-teal-900/30 dark:text-teal-200",
+ titleKey: "tools.micron_editor.title",
+ descriptionKey: "tools.micron_editor.description",
+ },
+ {
+ name: "reticulum-config-editor",
+ route: { name: "reticulum-config-editor" },
+ icon: "file-cog",
+ iconBg: "tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200",
+ titleKey: "tools.reticulum_config_editor.title",
+ descriptionKey: "tools.reticulum_config_editor.description",
+ },
+ {
+ name: "paper-message",
+ route: { name: "paper-message" },
+ icon: "qrcode",
+ iconBg: "tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200",
+ titleKey: "tools.paper_message.title",
+ descriptionKey: "tools.paper_message.description",
+ },
+ {
+ name: "rnode-flasher",
+ route: { name: "rnode-flasher" },
+ icon: null,
+ image: "/rnode-flasher/reticulum_logo_512.png",
+ imageClass: "w-8 h-8 rounded-full",
+ imageAlt: "RNode",
+ iconBg: "tool-card__icon bg-purple-50 text-purple-500 dark:bg-purple-900/30 dark:text-purple-200",
+ titleKey: "tools.rnode_flasher.title",
+ descriptionKey: "tools.rnode_flasher.description",
+ extraAction: {
+ href: "/rnode-flasher/index.html",
+ target: "_blank",
+ icon: "open-in-new",
+ },
+ },
+ {
+ name: "mesh-server",
+ route: { name: "mesh-server" },
+ icon: "server-network",
+ iconBg: "tool-card__icon bg-amber-50 text-amber-500 dark:bg-amber-900/30 dark:text-amber-200",
+ titleKey: "tools.mesh_server.title",
+ descriptionKey: "tools.mesh_server.description",
+ },
+ {
+ name: "rns-tunnel",
+ comingSoon: true,
+ icon: "tunnel",
+ iconBg: "tool-card__icon bg-indigo-50 text-indigo-500 dark:bg-indigo-900/30 dark:text-indigo-200",
+ titleKey: "tools.rns_tunnel.title",
+ descriptionKey: "tools.rns_tunnel.description",
+ },
+ {
+ name: "rns-filesync",
+ comingSoon: true,
+ icon: "folder-sync",
+ iconBg: "tool-card__icon bg-emerald-50 text-emerald-500 dark:bg-emerald-900/30 dark:text-emerald-200",
+ titleKey: "tools.rns_filesync.title",
+ descriptionKey: "tools.rns_filesync.description",
+ },
+ {
+ name: "debug-logs",
+ route: { name: "debug-logs" },
+ icon: "console",
+ iconBg: "tool-card__icon bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400",
+ titleKey: "debug.title",
+ descriptionKey: "debug.description",
+ customClass: "bg-amber-50/50 dark:bg-transparent",
+ },
+];
diff --git a/meshchatx/src/frontend/js/registries/navRegistry.js b/meshchatx/src/frontend/js/registries/navRegistry.js
new file mode 100644
index 00000000..52cca5ed
--- /dev/null
+++ b/meshchatx/src/frontend/js/registries/navRegistry.js
@@ -0,0 +1,29 @@
+// SPDX-License-Identifier: 0BSD
+
+import { createRegistry } from "./registryCore.js";
+
+/** @typedef {import('./coreNavEntries.js').NavEntry} NavEntry */
+
+/** @type {import('./registryCore.js').Registry<NavEntry>} */
+export const navRegistry = createRegistry("navRegistry");
+
+/**
+ * @param {NavEntry} entry
+ */
+export function registerNavItem(entry) {
+ navRegistry.register(entry);
+}
+
+/**
+ * @param {string} id
+ */
+export function unregisterNavItem(id) {
+ navRegistry.unregister(id);
+}
+
+/**
+ * @returns {NavEntry[]}
+ */
+export function listNavItems() {
+ return navRegistry.list();
+}
diff --git a/meshchatx/src/frontend/js/registries/registerCoreContributions.js b/meshchatx/src/frontend/js/registries/registerCoreContributions.js
new file mode 100644
index 00000000..62e10451
--- /dev/null
+++ b/meshchatx/src/frontend/js/registries/registerCoreContributions.js
@@ -0,0 +1,39 @@
+// SPDX-License-Identifier: 0BSD
+
+import { CORE_NAV_ENTRIES } from "./coreNavEntries.js";
+import { registerNavItem } from "./navRegistry.js";
+import { CORE_TOOLS_ENTRIES } from "./coreToolsEntries.js";
+import { registerTool } from "./toolsRegistry.js";
+import { CORE_COMMAND_ENTRIES } from "./coreCommandEntries.js";
+import { registerCommand } from "./commandRegistry.js";
+import { CORE_SETTINGS_SECTION_KEYWORDS } from "./coreSettingsSectionKeywords.js";
+import { registerSettingsSection } from "./settingsSectionRegistry.js";
+
+let coreRegistered = false;
+
+export function resetCoreContributionsForTests() {
+ coreRegistered = false;
+}
+
+export function registerCoreContributions() {
+ if (coreRegistered) {
+ return;
+ }
+ coreRegistered = true;
+
+ for (const entry of CORE_NAV_ENTRIES) {
+ registerNavItem(entry);
+ }
+
+ for (const entry of CORE_TOOLS_ENTRIES) {
+ registerTool(entry);
+ }
+
+ for (const entry of CORE_COMMAND_ENTRIES) {
+ registerCommand(entry);
+ }
+
+ for (const [sectionId, keywords] of Object.entries(CORE_SETTINGS_SECTION_KEYWORDS)) {
+ registerSettingsSection({ id: sectionId, keywords });
+ }
+}
diff --git a/meshchatx/src/frontend/js/registries/registryCore.js b/meshchatx/src/frontend/js/registries/registryCore.js
new file mode 100644
index 00000000..3655cc9d
--- /dev/null
+++ b/meshchatx/src/frontend/js/registries/registryCore.js
@@ -0,0 +1,45 @@
+// SPDX-License-Identifier: 0BSD
+
+/**
+ * @template T
+ * @typedef {Object} Registry
+ * @property {(entry: T) => void} register
+ * @property {(id: string) => void} unregister
+ * @property {(id: string) => T | undefined} get
+ * @property {() => T[]} list
+ * @property {() => void} clear
+ */
+
+/**
+ * @template {{ id: string }} T
+ * @param {string} name
+ * @returns {Registry<T>}
+ */
+export function createRegistry(name) {
+ /** @type {Map<string, T>} */
+ const entries = new Map();
+
+ return {
+ register(entry) {
+ if (!entry?.id) {
+ throw new Error(`${name}: entry requires an id`);
+ }
+ if (entries.has(entry.id)) {
+ throw new Error(`${name}: duplicate entry id "${entry.id}"`);
+ }
+ entries.set(entry.id, entry);
+ },
+ unregister(id) {
+ entries.delete(id);
+ },
+ get(id) {
+ return entries.get(id);
+ },
+ list() {
+ return Array.from(entries.values());
+ },
+ clear() {
+ entries.clear();
+ },
+ };
+}
diff --git a/meshchatx/src/frontend/js/registries/settingsSectionRegistry.js b/meshchatx/src/frontend/js/registries/settingsSectionRegistry.js
new file mode 100644
index 00000000..f36ed2f6
--- /dev/null
+++ b/meshchatx/src/frontend/js/registries/settingsSectionRegistry.js
@@ -0,0 +1,63 @@
+// SPDX-License-Identifier: 0BSD
+
+import { createRegistry } from "./registryCore.js";
+
+/**
+ * @typedef {Object} SettingsSectionEntry
+ * @property {string} id
+ * @property {string[]} keywords
+ * @property {import('vue').Component | null} [component]
+ * @property {string | null} [pluginId]
+ */
+
+/** @type {import('./registryCore.js').Registry<SettingsSectionEntry>} */
+export const settingsSectionRegistry = createRegistry("settingsSectionRegistry");
+
+/**
+ * @param {Omit<SettingsSectionEntry, 'id'> & { id: string }} entry
+ */
+export function registerSettingsSection(entry) {
+ settingsSectionRegistry.register(entry);
+}
+
+/**
+ * @param {string} id
+ */
+export function unregisterSettingsSection(id) {
+ settingsSectionRegistry.unregister(id);
+}
+
+/**
+ * @returns {SettingsSectionEntry[]}
+ */
+export function listSettingsSections() {
+ return settingsSectionRegistry.list();
+}
+
+/**
+ * @param {string} sectionKey
+ * @returns {string[] | undefined}
+ */
+export function getSettingsSectionKeywords(sectionKey) {
+ return settingsSectionRegistry.get(sectionKey)?.keywords;
+}
+
+/**
+ * @returns {Record<string, string[]>}
+ */
+export function getAllSettingsSectionKeywords() {
+ /** @type {Record<string, string[]>} */
+ const map = {};
+ for (const entry of settingsSectionRegistry.list()) {
+ map[entry.id] = entry.keywords;
+ }
+ return map;
+}
+
+/**
+ * @param {string} sectionKey
+ * @returns {import('vue').Component | null | undefined}
+ */
+export function getSettingsSectionComponent(sectionKey) {
+ return settingsSectionRegistry.get(sectionKey)?.component;
+}
diff --git a/meshchatx/src/frontend/js/registries/toolsRegistry.js b/meshchatx/src/frontend/js/registries/toolsRegistry.js
new file mode 100644
index 00000000..a0c811e6
--- /dev/null
+++ b/meshchatx/src/frontend/js/registries/toolsRegistry.js
@@ -0,0 +1,29 @@
+// SPDX-License-Identifier: 0BSD
+
+import { createRegistry } from "./registryCore.js";
+
+/** @typedef {import('./coreToolsEntries.js').ToolEntry} ToolEntry */
+
+/** @type {import('./registryCore.js').Registry<ToolEntry>} */
+export const toolsRegistry = createRegistry("toolsRegistry");
+
+/**
+ * @param {ToolEntry} entry
+ */
+export function registerTool(entry) {
+ toolsRegistry.register({ ...entry, id: entry.name });
+}
+
+/**
+ * @param {string} name
+ */
+export function unregisterTool(name) {
+ toolsRegistry.unregister(name);
+}
+
+/**
+ * @returns {ToolEntry[]}
+ */
+export function listTools() {
+ return toolsRegistry.list().map(({ id: _id, ...entry }) => entry);
+}
diff --git a/meshchatx/src/frontend/js/registries/wsEventBridge.js b/meshchatx/src/frontend/js/registries/wsEventBridge.js
new file mode 100644
index 00000000..09e79f56
--- /dev/null
+++ b/meshchatx/src/frontend/js/registries/wsEventBridge.js
@@ -0,0 +1,28 @@
+// SPDX-License-Identifier: 0BSD
+
+import WebSocketConnection from "../WebSocketConnection.js";
+import { dispatchWsEvent } from "./wsEventRegistry.js";
+
+let bridgeInstalled = false;
+
+export function installWsEventBridge() {
+ if (bridgeInstalled) {
+ return;
+ }
+ bridgeInstalled = true;
+
+ WebSocketConnection.on("message", async (message) => {
+ try {
+ const json = JSON.parse(message.data);
+ if (json && typeof json.type === "string") {
+ await dispatchWsEvent(json.type, json);
+ }
+ } catch {
+ // non-json payloads are ignored by the typed router
+ }
+ });
+}
+
+export function resetWsEventBridgeForTests() {
+ bridgeInstalled = false;
+}
diff --git a/meshchatx/src/frontend/js/registries/wsEventRegistry.js b/meshchatx/src/frontend/js/registries/wsEventRegistry.js
new file mode 100644
index 00000000..bef69d53
--- /dev/null
+++ b/meshchatx/src/frontend/js/registries/wsEventRegistry.js
@@ -0,0 +1,43 @@
+// SPDX-License-Identifier: 0BSD
+
+import mitt from "mitt";
+
+/** @type {import('mitt').Emitter<Record<string, unknown>>} */
+const emitter = mitt();
+
+/**
+ * @param {string} type
+ * @param {(payload: Record<string, unknown>) => void | Promise<void>} handler
+ */
+export function onWsEvent(type, handler) {
+ emitter.on(type, handler);
+}
+
+/**
+ * @param {string} type
+ * @param {(payload: Record<string, unknown>) => void | Promise<void>} handler
+ */
+export function offWsEvent(type, handler) {
+ emitter.off(type, handler);
+}
+
+/**
+ * @param {string} type
+ * @param {Record<string, unknown>} payload
+ */
+export async function dispatchWsEvent(type, payload) {
+ const handlers = emitter.all.get(type);
+ if (!handlers || handlers.size === 0) {
+ return;
+ }
+ for (const handler of handlers) {
+ await handler(payload);
+ }
+}
+
+/**
+ * @returns {string[]}
+ */
+export function listRegisteredWsEventTypes() {
+ return Array.from(emitter.all.keys());
+}
diff --git a/meshchatx/src/frontend/js/settings/settingsTabs.js b/meshchatx/src/frontend/js/settings/settingsTabs.js
index 4e4c2d91..889767ef 100644
--- a/meshchatx/src/frontend/js/settings/settingsTabs.js
+++ b/meshchatx/src/frontend/js/settings/settingsTabs.js
@@ -38,7 +38,7 @@ export const SETTINGS_TABS = [
id: "maintenance",
labelKey: "settings.tabs.maintenance",
descriptionKey: "settings.tabs.maintenance_desc",
- sections: ["maintenance"],
+ sections: ["maintenance", "plugins"],
},
];
diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index 82ccb744..67f755c0 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -645,6 +645,32 @@
"import_success": "Importiert {imported}, übersprungen {skipped_duplicates} Duplikate, ungültig {skipped_invalid}",
"import_failed": "GIF-Import fehlgeschlagen"
},
+ "plugins": {
+ "settings": {
+ "title": "Plugins",
+ "description": "MeshChatX-Plugins installieren, aktivieren und Berechtigungen prüfen.",
+ "enable": "Aktivieren",
+ "disable": "Deaktivieren",
+ "remove": "Entfernen",
+ "permissions": "Berechtigungen",
+ "install_zip": "Plugin aus ZIP-Archiv installieren",
+ "enabled": "Plugin aktiviert",
+ "disabled": "Plugin deaktiviert",
+ "removed": "Plugin entfernt",
+ "installed": "Plugin installiert",
+ "auto_disabled": "Plugin automatisch deaktiviert: {reason}",
+ "kill_switch": "Ein Plugin wurde deaktiviert: {reason}"
+ },
+ "transport_node_monitor": {
+ "nav": "Transportknoten",
+ "title": "Transportknoten-Monitor",
+ "description": "Beobachtete Transportknoten-Ziele und Pfad-Hop-Anzahl verfolgen.",
+ "watch_hash": "Ziel-Hash",
+ "watch_hash_placeholder": "Ziel-Hash zum Beobachten eingeben",
+ "add_watch": "Beobachtung hinzufügen",
+ "no_path": "Kein Pfad"
+ }
+ },
"maintenance": {
"title": "Wartung & Daten",
"description": "Verwalten Sie Ihre lokalen Daten, löschen Sie Caches und sichern Sie Konversationen.",
diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index 621080d9..3e9c51e6 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -645,6 +645,32 @@
"import_success": "Imported {imported}, skipped {skipped_duplicates} duplicates, invalid {skipped_invalid}",
"import_failed": "Failed to import GIFs"
},
+ "plugins": {
+ "settings": {
+ "title": "Plugins",
+ "description": "Install, enable, and review permissions for MeshChatX plugins.",
+ "enable": "Enable",
+ "disable": "Disable",
+ "remove": "Remove",
+ "permissions": "Permissions",
+ "install_zip": "Install plugin from ZIP archive",
+ "enabled": "Plugin enabled",
+ "disabled": "Plugin disabled",
+ "removed": "Plugin removed",
+ "installed": "Plugin installed",
+ "auto_disabled": "Plugin auto-disabled: {reason}",
+ "kill_switch": "A plugin was disabled: {reason}"
+ },
+ "transport_node_monitor": {
+ "nav": "Transport Nodes",
+ "title": "Transport Node Monitor",
+ "description": "Watch transport node destinations and path hop counts.",
+ "watch_hash": "Destination hash",
+ "watch_hash_placeholder": "Enter a destination hash to watch",
+ "add_watch": "Add watch",
+ "no_path": "No path"
+ }
+ },
"maintenance": {
"title": "Maintenance & Data",
"description": "Manage your local data, clear caches, and backup conversations.",
diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index d2943c9e..6e86575b 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -645,6 +645,32 @@
"import_success": "{imported}importado, duplicados{skipped_duplicates}saltados, inválido{skipped_invalid}",
"import_failed": "No importado GIF"
},
+ "plugins": {
+ "settings": {
+ "title": "Plugins",
+ "description": "Instalar, habilitar y revisar permisos de plugins de MeshChatX.",
+ "enable": "Habilitar",
+ "disable": "Deshabilitar",
+ "remove": "Eliminar",
+ "permissions": "Permisos",
+ "install_zip": "Instalar plugin desde archivo ZIP",
+ "enabled": "Plugin habilitado",
+ "disabled": "Plugin deshabilitado",
+ "removed": "Plugin eliminado",
+ "installed": "Plugin instalado",
+ "auto_disabled": "Plugin deshabilitado automáticamente: {reason}",
+ "kill_switch": "Se deshabilitó un plugin: {reason}"
+ },
+ "transport_node_monitor": {
+ "nav": "Nodos de transporte",
+ "title": "Monitor de nodos de transporte",
+ "description": "Supervisar destinos de nodos de transporte y saltos de ruta.",
+ "watch_hash": "Hash de destino",
+ "watch_hash_placeholder": "Introduce un hash de destino para vigilar",
+ "add_watch": "Añadir vigilancia",
+ "no_path": "Sin ruta"
+ }
+ },
"maintenance": {
"title": "Datos de mantenimiento",
"description": "Gestione sus datos locales, jaulas claras y conversaciones de respaldo.",
diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json
index 67e83beb..032b1e44 100644
--- a/meshchatx/src/frontend/locales/fi.json
+++ b/meshchatx/src/frontend/locales/fi.json
@@ -645,6 +645,32 @@
"import_success": "Tuotiin {imported}, ohitettiin {skipped_duplicates} kaksoiskappaletta, kelvottomia {skipped_invalid}",
"import_failed": "GIFien tuonti epäonnistui"
},
+ "plugins": {
+ "settings": {
+ "title": "Liitännäiset",
+ "description": "Asenna, ota käyttöön ja tarkista MeshChatX-liitännäisten oikeudet.",
+ "enable": "Ota käyttöön",
+ "disable": "Poista käytöstä",
+ "remove": "Poista",
+ "permissions": "Oikeudet",
+ "install_zip": "Asenna liitännäinen ZIP-arkistosta",
+ "enabled": "Liitännäinen otettu käyttöön",
+ "disabled": "Liitännäinen poistettu käytöstä",
+ "removed": "Liitännäinen poistettu",
+ "installed": "Liitännäinen asennettu",
+ "auto_disabled": "Liitännäinen poistettiin käytöstä automaattisesti: {reason}",
+ "kill_switch": "Liitännäinen poistettiin käytöstä: {reason}"
+ },
+ "transport_node_monitor": {
+ "nav": "Kuljetussolmut",
+ "title": "Kuljetussolmujen valvonta",
+ "description": "Seuraa kuljetussolmujen kohteita ja reitin hyppyjen määrää.",
+ "watch_hash": "Kohdehash",
+ "watch_hash_placeholder": "Syötä seurattava kohdehash",
+ "add_watch": "Lisää seuranta",
+ "no_path": "Ei reittiä"
+ }
+ },
"maintenance": {
"title": "Ylläpito ja tiedot",
"description": "Hallinnoi paikallista dataa, pyyhi välimuisti ja varmuuskopioi keskustelut.",
diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index f4f6c2ae..b224d39b 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -645,6 +645,32 @@
"import_success": "Importé{imported}, dédoublé{skipped_duplicates}duplicata, invalide{skipped_invalid}",
"import_failed": "Échec de l'importation des FPG"
},
+ "plugins": {
+ "settings": {
+ "title": "Plugins",
+ "description": "Installer, activer et examiner les permissions des plugins MeshChatX.",
+ "enable": "Activer",
+ "disable": "Désactiver",
+ "remove": "Supprimer",
+ "permissions": "Permissions",
+ "install_zip": "Installer un plugin depuis une archive ZIP",
+ "enabled": "Plugin activé",
+ "disabled": "Plugin désactivé",
+ "removed": "Plugin supprimé",
+ "installed": "Plugin installé",
+ "auto_disabled": "Plugin désactivé automatiquement : {reason}",
+ "kill_switch": "Un plugin a été désactivé : {reason}"
+ },
+ "transport_node_monitor": {
+ "nav": "Nœuds de transport",
+ "title": "Moniteur de nœuds de transport",
+ "description": "Surveiller les destinations et le nombre de sauts de chemin des nœuds de transport.",
+ "watch_hash": "Hash de destination",
+ "watch_hash_placeholder": "Saisir un hash de destination à surveiller",
+ "add_watch": "Ajouter une surveillance",
+ "no_path": "Aucun chemin"
+ }
+ },
"maintenance": {
"title": "Maintenance et données",
"description": "Gérer vos données locales, effacer les caches et les conversations de sauvegarde.",
diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index 6986e4d5..41862496 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -645,6 +645,32 @@
"import_success": "Importate {imported}, saltate {skipped_duplicates} duplicate, non valide {skipped_invalid}",
"import_failed": "Importazione GIF non riuscita"
},
+ "plugins": {
+ "settings": {
+ "title": "Plugin",
+ "description": "Installa, abilita e rivedi i permessi dei plugin MeshChatX.",
+ "enable": "Abilita",
+ "disable": "Disabilita",
+ "remove": "Rimuovi",
+ "permissions": "Permessi",
+ "install_zip": "Installa plugin da archivio ZIP",
+ "enabled": "Plugin abilitato",
+ "disabled": "Plugin disabilitato",
+ "removed": "Plugin rimosso",
+ "installed": "Plugin installato",
+ "auto_disabled": "Plugin disabilitato automaticamente: {reason}",
+ "kill_switch": "Un plugin è stato disabilitato: {reason}"
+ },
+ "transport_node_monitor": {
+ "nav": "Nodi di trasporto",
+ "title": "Monitor nodi di trasporto",
+ "description": "Monitora le destinazioni dei nodi di trasporto e i salti di percorso.",
+ "watch_hash": "Hash destinazione",
+ "watch_hash_placeholder": "Inserisci un hash destinazione da monitorare",
+ "add_watch": "Aggiungi monitoraggio",
+ "no_path": "Nessun percorso"
+ }
+ },
"maintenance": {
"title": "Manutenzione e Dati",
"description": "Gestisci i tuoi dati locali, svuota le cache e fai il backup delle conversazioni.",
diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index d1336176..057cba62 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -645,6 +645,32 @@
"import_success": "Geïmporteerd{imported}, overgeslagen{skipped_duplicates}duplicaten, ongeldig{skipped_invalid}",
"import_failed": "Kon GIF's niet importeren"
},
+ "plugins": {
+ "settings": {
+ "title": "Plugins",
+ "description": "Installeer, schakel in en controleer rechten voor MeshChatX-plugins.",
+ "enable": "Inschakelen",
+ "disable": "Uitschakelen",
+ "remove": "Verwijderen",
+ "permissions": "Rechten",
+ "install_zip": "Plugin installeren vanuit ZIP-archief",
+ "enabled": "Plugin ingeschakeld",
+ "disabled": "Plugin uitgeschakeld",
+ "removed": "Plugin verwijderd",
+ "installed": "Plugin geïnstalleerd",
+ "auto_disabled": "Plugin automatisch uitgeschakeld: {reason}",
+ "kill_switch": "Een plugin is uitgeschakeld: {reason}"
+ },
+ "transport_node_monitor": {
+ "nav": "Transportnodes",
+ "title": "Transportnode-monitor",
+ "description": "Bewaak transportnode-bestemmingen en aantal routehops.",
+ "watch_hash": "Bestemmingshash",
+ "watch_hash_placeholder": "Voer een bestemmingshash in om te volgen",
+ "add_watch": "Volgen toevoegen",
+ "no_path": "Geen pad"
+ }
+ },
"maintenance": {
"title": "Onderhoud & gegevens",
"description": "Beheer uw lokale gegevens, duidelijke caches en back-upgesprekken.",
diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index aae4a415..abfe8e67 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -645,6 +645,32 @@
"import_success": "Импортировано {imported}, пропущено дубликатов {skipped_duplicates}, неверных {skipped_invalid}",
"import_failed": "Не удалось импортировать GIF"
},
+ "plugins": {
+ "settings": {
+ "title": "Плагины",
+ "description": "Установка, включение и проверка разрешений плагинов MeshChatX.",
+ "enable": "Включить",
+ "disable": "Отключить",
+ "remove": "Удалить",
+ "permissions": "Разрешения",
+ "install_zip": "Установить плагин из ZIP-архива",
+ "enabled": "Плагин включён",
+ "disabled": "Плагин отключён",
+ "removed": "Плагин удалён",
+ "installed": "Плагин установлен",
+ "auto_disabled": "Плагин автоматически отключён: {reason}",
+ "kill_switch": "Плагин был отключён: {reason}"
+ },
+ "transport_node_monitor": {
+ "nav": "Транспортные узлы",
+ "title": "Монитор транспортных узлов",
+ "description": "Отслеживание назначений транспортных узлов и числа хопов маршрута.",
+ "watch_hash": "Хеш назначения",
+ "watch_hash_placeholder": "Введите хеш назначения для отслеживания",
+ "add_watch": "Добавить отслеживание",
+ "no_path": "Нет маршрута"
+ }
+ },
"maintenance": {
"title": "Обслуживание и данные",
"description": "Управляйте локальными данными, очищайте кэши и создавайте резервные копии разговоров.",
diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index 8af27264..78b19a44 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -645,6 +645,32 @@
"import_success": "已导入 {imported} 个,跳过 {skipped_duplicates} 个重复,{skipped_invalid} 个无效",
"import_failed": "导入 GIF 失败"
},
+ "plugins": {
+ "settings": {
+ "title": "插件",
+ "description": "安装、启用并查看 MeshChatX 插件权限。",
+ "enable": "启用",
+ "disable": "禁用",
+ "remove": "移除",
+ "permissions": "权限",
+ "install_zip": "从 ZIP 压缩包安装插件",
+ "enabled": "插件已启用",
+ "disabled": "插件已禁用",
+ "removed": "插件已移除",
+ "installed": "插件已安装",
+ "auto_disabled": "插件已自动禁用:{reason}",
+ "kill_switch": "插件已被禁用:{reason}"
+ },
+ "transport_node_monitor": {
+ "nav": "传输节点",
+ "title": "传输节点监视器",
+ "description": "监视传输节点目标地址和路径跳数。",
+ "watch_hash": "目标哈希",
+ "watch_hash_placeholder": "输入要监视的目标哈希",
+ "add_watch": "添加监视",
+ "no_path": "无路径"
+ }
+ },
"maintenance": {
"title": "数据维护",
"description": "管理您的本地数据、清除缓存和备份对话。",
diff --git a/meshchatx/src/frontend/main.js b/meshchatx/src/frontend/main.js
index cdf69f41..ca5f9ca4 100644
--- a/meshchatx/src/frontend/main.js
+++ b/meshchatx/src/frontend/main.js
@@ -14,8 +14,14 @@ import "./fonts/RobotoMonoNerdFont/font.css";
import { startCodec2ScriptsBackgroundLoad } from "./js/Codec2Loader";
import { createApiClient } from "./js/apiClient.js";
import { fetchCsrfToken } from "./js/csrfToken.js";
+import { registerCoreContributions } from "./js/registries/registerCoreContributions.js";
+import { installWsEventBridge } from "./js/registries/wsEventBridge.js";
+import { pluginHost } from "./js/plugins/PluginHost.js";
import "./js/HeapMonitor.js";
+registerCoreContributions();
+installWsEventBridge();
+
import App from "./components/App.vue";
import ChangelogModal from "./components/ChangelogModal.vue";
import TutorialModal from "./components/TutorialModal.vue";
@@ -292,6 +298,12 @@ const router = createRouter({
meta: { isPopout: true },
component: () => import("./components/call/CallPage.vue"),
},
+ {
+ name: "plugin-transport-node-monitor",
+ path: "/plugins/com.meshchatx.transport-node-monitor",
+ component: () => import("./components/plugins/PluginPage.vue"),
+ props: { pluginId: "com.meshchatx.transport-node-monitor" },
+ },
{
name: "changelog",
path: "/changelog",
@@ -399,6 +411,11 @@ function bootstrap() {
splash.remove();
}
void startCodec2ScriptsBackgroundLoad();
+ if (GlobalState.authenticated || !GlobalState.authEnabled) {
+ void pluginHost.loadEnabledPlugins(window.api).catch((error) => {
+ console.debug("Plugin host bootstrap failed:", error);
+ });
+ }
}
bootstrap();
diff --git a/pyproject.toml b/pyproject.toml
index b80da60c..a967ef81 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -35,6 +35,7 @@ dependencies = [
"lxst>=0.4.8",
"miniaudio (>=1.70,<2.0)",
"cbor2>=6.1.1",
+ "wasmtime>=28.0.0",
]
[project.scripts]
@@ -53,7 +54,7 @@ exclude = ["tests*"]
namespaces = false
[tool.setuptools.package-data]
-meshchatx = ["public/**/*", "public/*", "src/backend/data/community_interfaces.json", "src/backend/data/licenses_frontend.json", "src/backend/data/licenses_backend.json", "src/backend/data/THIRD_PARTY_NOTICES.txt", "src/frontend/public/repository-server-index.html"]
+meshchatx = ["public/**/*", "public/*", "src/backend/data/community_interfaces.json", "src/backend/data/licenses_frontend.json", "src/backend/data/licenses_backend.json", "src/backend/data/THIRD_PARTY_NOTICES.txt", "src/backend/data/plugins/**/*", "src/frontend/public/repository-server-index.html"]
[tool.setuptools.exclude-package-data]
meshchatx = ["public/repository-server-bundled/**"]
diff --git a/tests/backend/fixtures/http_api_routes.json b/tests/backend/fixtures/http_api_routes.json
index e725bed3..a9243348 100644
--- a/tests/backend/fixtures/http_api_routes.json
+++ b/tests/backend/fixtures/http_api_routes.json
@@ -1,1328 +1,1368 @@
{
- "routes": [
- {
- "method": "GET",
- "path": "/"
- },
- {
- "method": "GET",
- "path": "/api/v1/announce"
- },
- {
- "method": "GET",
- "path": "/api/v1/announces"
- },
- {
- "method": "POST",
- "path": "/api/v1/announces/query"
- },
- {
- "method": "GET",
- "path": "/api/v1/app/changelog"
- },
- {
- "method": "POST",
- "path": "/api/v1/app/changelog/seen"
- },
- {
- "method": "GET",
- "path": "/api/v1/app/info"
- },
- {
- "method": "POST",
- "path": "/api/v1/app/integrity/acknowledge"
- },
- {
- "method": "POST",
- "path": "/api/v1/app/shutdown"
- },
- {
- "method": "POST",
- "path": "/api/v1/app/tutorial/seen"
- },
- {
- "method": "POST",
- "path": "/api/v1/auth/login"
- },
- {
- "method": "POST",
- "path": "/api/v1/auth/logout"
- },
- {
- "method": "POST",
- "path": "/api/v1/auth/setup"
- },
- {
- "method": "GET",
- "path": "/api/v1/auth/status"
- },
- {
- "method": "GET",
- "path": "/api/v1/blocked-destinations"
- },
- {
- "method": "POST",
- "path": "/api/v1/blocked-destinations"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/blocked-destinations/{destination_hash}"
- },
- {
- "method": "POST",
- "path": "/api/v1/bots/announce"
- },
- {
- "method": "POST",
- "path": "/api/v1/bots/delete"
- },
- {
- "method": "GET",
- "path": "/api/v1/bots/export"
- },
- {
- "method": "POST",
- "path": "/api/v1/bots/restart"
- },
- {
- "method": "POST",
- "path": "/api/v1/bots/start"
- },
- {
- "method": "GET",
- "path": "/api/v1/bots/status"
- },
- {
- "method": "POST",
- "path": "/api/v1/bots/stop"
- },
- {
- "method": "GET",
- "path": "/api/v1/bots/subprocess-log"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/bots/update"
- },
- {
- "method": "GET",
- "path": "/api/v1/community-interfaces"
- },
- {
- "method": "POST",
- "path": "/api/v1/community-interfaces/refresh"
- },
- {
- "method": "GET",
- "path": "/api/v1/comports"
- },
- {
- "method": "GET",
- "path": "/api/v1/config"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/config"
- },
- {
- "method": "POST",
- "path": "/api/v1/database/backup"
- },
- {
- "method": "GET",
- "path": "/api/v1/database/backup/download"
- },
- {
- "method": "GET",
- "path": "/api/v1/database/backups"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/database/backups/{filename}"
- },
- {
- "method": "GET",
- "path": "/api/v1/database/backups/{filename}/download"
- },
- {
- "method": "GET",
- "path": "/api/v1/database/health"
- },
- {
- "method": "POST",
- "path": "/api/v1/database/recover"
- },
- {
- "method": "POST",
- "path": "/api/v1/database/restore"
- },
- {
- "method": "POST",
- "path": "/api/v1/database/snapshot"
- },
- {
- "method": "GET",
- "path": "/api/v1/database/snapshots"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/database/snapshots/{filename}"
- },
- {
- "method": "GET",
- "path": "/api/v1/database/snapshots/{filename}/download"
- },
- {
- "method": "POST",
- "path": "/api/v1/database/vacuum"
- },
- {
- "method": "GET",
- "path": "/api/v1/debug/access-attempts"
- },
- {
- "method": "GET",
- "path": "/api/v1/debug/logs"
- },
- {
- "method": "GET",
- "path": "/api/v1/destination/{destination_hash}/custom-display-name"
- },
- {
- "method": "POST",
- "path": "/api/v1/destination/{destination_hash}/custom-display-name/update"
- },
- {
- "method": "POST",
- "path": "/api/v1/destination/{destination_hash}/drop-path"
- },
- {
- "method": "GET",
- "path": "/api/v1/destination/{destination_hash}/lxmf-stamp-info"
- },
- {
- "method": "GET",
- "path": "/api/v1/destination/{destination_hash}/path"
- },
- {
- "method": "POST",
- "path": "/api/v1/destination/{destination_hash}/request-path"
- },
- {
- "method": "GET",
- "path": "/api/v1/destination/{destination_hash}/signal-metrics"
- },
- {
- "method": "GET",
- "path": "/api/v1/diagnostics/memory"
- },
- {
- "method": "GET",
- "path": "/api/v1/diagnostics/memory/gc"
- },
- {
- "method": "POST",
- "path": "/api/v1/diagnostics/memory/gc/collect"
- },
- {
- "method": "GET",
- "path": "/api/v1/diagnostics/memory/heap"
- },
- {
- "method": "GET",
- "path": "/api/v1/diagnostics/memory/referrers"
- },
- {
- "method": "POST",
- "path": "/api/v1/diagnostics/memory/reset"
- },
- {
- "method": "POST",
- "path": "/api/v1/diagnostics/memory/snapshot"
- },
- {
- "method": "GET",
- "path": "/api/v1/docs/export"
- },
- {
- "method": "GET",
- "path": "/api/v1/docs/export/reticulum"
- },
- {
- "method": "GET",
- "path": "/api/v1/docs/search"
- },
- {
- "method": "GET",
- "path": "/api/v1/docs/status"
- },
- {
- "method": "POST",
- "path": "/api/v1/docs/switch"
- },
- {
- "method": "POST",
- "path": "/api/v1/docs/upload"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/docs/version/{version}"
- },
- {
- "method": "GET",
- "path": "/api/v1/favourites"
- },
- {
- "method": "POST",
- "path": "/api/v1/favourites/add"
- },
- {
- "method": "POST",
- "path": "/api/v1/favourites/import"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/favourites/{destination_hash}"
- },
- {
- "method": "POST",
- "path": "/api/v1/favourites/{destination_hash}/rename"
- },
- {
- "method": "GET",
- "path": "/api/v1/gifs"
- },
- {
- "method": "POST",
- "path": "/api/v1/gifs"
- },
- {
- "method": "GET",
- "path": "/api/v1/gifs/export"
- },
- {
- "method": "POST",
- "path": "/api/v1/gifs/import"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/gifs/{gif_id}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/gifs/{gif_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/gifs/{gif_id}/image"
- },
- {
- "method": "POST",
- "path": "/api/v1/gifs/{gif_id}/use"
- },
- {
- "method": "GET",
- "path": "/api/v1/identities"
- },
- {
- "method": "POST",
- "path": "/api/v1/identities/create"
- },
- {
- "method": "GET",
- "path": "/api/v1/identities/export-all"
- },
- {
- "method": "POST",
- "path": "/api/v1/identities/switch"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/identities/{identity_hash}"
- },
- {
- "method": "GET",
- "path": "/api/v1/identity/backup/base32"
- },
- {
- "method": "GET",
- "path": "/api/v1/identity/backup/download"
- },
- {
- "method": "POST",
- "path": "/api/v1/identity/restore"
- },
- {
- "method": "GET",
- "path": "/api/v1/interface-stats"
- },
- {
- "method": "GET",
- "path": "/api/v1/licenses"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf-messages/attachment/{message_hash}/{attachment_type}"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/lxmf-messages/conversation/{destination_hash}"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf-messages/conversation/{destination_hash}"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf-messages/reactions"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf-messages/send"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/lxmf-messages/{hash}"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf-messages/{hash}/cancel"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf-messages/{hash}/spam"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf-messages/{message_hash}/uri"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/conversation-pins"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/conversation-pins/toggle"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/conversations"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/conversations/bulk-delete"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/conversations/bulk-mark-as-read"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/conversations/move-to-folder"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/conversations/{destination_hash}/mark-as-read"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/folders"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/folders"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/folders/export"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/folders/import"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/lxmf/folders/{id}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/lxmf/folders/{id}"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/propagation-node/restart"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/propagation-node/status"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/propagation-node/stop"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/propagation-node/stop-sync"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/propagation-node/sync"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/propagation-nodes"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/sieve-filters"
- },
- {
- "method": "PUT",
- "path": "/api/v1/lxmf/sieve-filters"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/message-blocklist"
- },
- {
- "method": "PUT",
- "path": "/api/v1/lxmf/message-blocklist"
- },
- {
- "method": "GET",
- "path": "/api/v1/lxmf/message-blocklist/export"
- },
- {
- "method": "POST",
- "path": "/api/v1/lxmf/message-blocklist/import"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/maintenance/announces"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/maintenance/archives"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/maintenance/docs/reticulum"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/maintenance/favourites"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/maintenance/gifs"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/maintenance/lxmf-icons"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/maintenance/messages"
- },
- {
- "method": "GET",
- "path": "/api/v1/maintenance/messages/export"
- },
- {
- "method": "POST",
- "path": "/api/v1/maintenance/messages/import"
- },
- {
- "method": "POST",
- "path": "/api/v1/maintenance/messages/import-file"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/maintenance/path-table"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/maintenance/stickers"
- },
- {
- "method": "GET",
- "path": "/api/v1/map/drawings"
- },
- {
- "method": "POST",
- "path": "/api/v1/map/drawings"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/map/drawings/{drawing_id}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/map/drawings/{drawing_id}"
- },
- {
- "method": "POST",
- "path": "/api/v1/map/export"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/map/export/{export_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/map/export/{export_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/map/export/{export_id}/download"
- },
- {
- "method": "GET",
- "path": "/api/v1/map/mbtiles"
- },
- {
- "method": "POST",
- "path": "/api/v1/map/mbtiles/active"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/map/mbtiles/{filename}"
- },
- {
- "method": "GET",
- "path": "/api/v1/map/offline"
- },
- {
- "method": "POST",
- "path": "/api/v1/map/offline"
- },
- {
- "method": "GET",
- "path": "/api/v1/map/tiles/{z}/{x}/{y}"
- },
- {
- "method": "GET",
- "path": "/api/v1/meshchatx-docs/content"
- },
- {
- "method": "GET",
- "path": "/api/v1/meshchatx-docs/list"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/nomadnet/archives"
- },
- {
- "method": "GET",
- "path": "/api/v1/nomadnet/archives"
- },
- {
- "method": "POST",
- "path": "/api/v1/nomadnetwork/{destination_hash}/identify"
- },
- {
- "method": "GET",
- "path": "/api/v1/notifications"
- },
- {
- "method": "POST",
- "path": "/api/v1/notifications/mark-as-viewed"
- },
- {
- "method": "GET",
- "path": "/api/v1/page-nodes"
- },
- {
- "method": "POST",
- "path": "/api/v1/page-nodes"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/page-nodes/{node_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/page-nodes/{node_id}"
- },
- {
- "method": "POST",
- "path": "/api/v1/page-nodes/{node_id}/announce"
- },
- {
- "method": "GET",
- "path": "/api/v1/page-nodes/{node_id}/files"
- },
- {
- "method": "POST",
- "path": "/api/v1/page-nodes/{node_id}/files"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/page-nodes/{node_id}/files/{file_name}"
- },
- {
- "method": "GET",
- "path": "/api/v1/page-nodes/{node_id}/pages"
- },
- {
- "method": "POST",
- "path": "/api/v1/page-nodes/{node_id}/pages"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/page-nodes/{node_id}/pages/{page_name}"
- },
- {
- "method": "GET",
- "path": "/api/v1/page-nodes/{node_id}/pages/{page_name}"
- },
- {
- "method": "PUT",
- "path": "/api/v1/page-nodes/{node_id}/rename"
- },
- {
- "method": "POST",
- "path": "/api/v1/page-nodes/{node_id}/start"
- },
- {
- "method": "POST",
- "path": "/api/v1/page-nodes/{node_id}/stop"
- },
- {
- "method": "GET",
- "path": "/api/v1/path-table"
- },
- {
- "method": "POST",
- "path": "/api/v1/path-table"
- },
- {
- "method": "GET",
- "path": "/api/v1/ping/{destination_hash}/lxmf.delivery"
- },
- {
- "method": "POST",
- "path": "/api/v1/repository-server/http/restart"
- },
- {
- "method": "POST",
- "path": "/api/v1/repository-server/http/start"
- },
- {
- "method": "POST",
- "path": "/api/v1/repository-server/http/stop"
- },
- {
- "method": "GET",
- "path": "/api/v1/repository-server/list"
- },
- {
- "method": "POST",
- "path": "/api/v1/repository-server/refresh-bundled"
- },
- {
- "method": "GET",
- "path": "/api/v1/repository-server/status"
- },
- {
- "method": "POST",
- "path": "/api/v1/repository-server/upload"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/repository-server/upload/{name}"
- },
- {
- "method": "GET",
- "path": "/api/v1/reticulum/blackhole"
- },
- {
- "method": "GET",
- "path": "/api/v1/reticulum/config/raw"
- },
- {
- "method": "PUT",
- "path": "/api/v1/reticulum/config/raw"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/config/reset"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/disable-transport"
- },
- {
- "method": "GET",
- "path": "/api/v1/reticulum/discovered-interfaces"
- },
- {
- "method": "GET",
- "path": "/api/v1/reticulum/discovery"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/reticulum/discovery"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/enable-transport"
- },
- {
- "method": "GET",
- "path": "/api/v1/reticulum/interfaces"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/interfaces/add"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/interfaces/delete"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/interfaces/disable"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/interfaces/enable"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/interfaces/export"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/interfaces/import"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/interfaces/import-preview"
- },
- {
- "method": "POST",
- "path": "/api/v1/reticulum/reload"
- },
- {
- "method": "POST",
- "path": "/api/v1/rncp/fetch"
- },
- {
- "method": "POST",
- "path": "/api/v1/rncp/listen"
- },
- {
- "method": "POST",
- "path": "/api/v1/rncp/send"
- },
- {
- "method": "GET",
- "path": "/api/v1/rncp/status"
- },
- {
- "method": "POST",
- "path": "/api/v1/rncp/stop"
- },
- {
- "method": "GET",
- "path": "/api/v1/rncp/transfer/{transfer_id}"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnpath/drop"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnpath/drop-queues"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnpath/drop-via"
- },
- {
- "method": "GET",
- "path": "/api/v1/rnpath/rates"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnpath/request"
- },
- {
- "method": "GET",
- "path": "/api/v1/rnpath/table"
- },
- {
- "method": "GET",
- "path": "/api/v1/rnpath/trace/{destination_hash}"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnprobe"
- },
- {
- "method": "GET",
- "path": "/api/v1/rnsh/sessions"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnsh/sessions"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/rnsh/sessions/{session_id}"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnsh/sessions/{session_id}/clear"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnsh/sessions/{session_id}/input"
- },
- {
- "method": "GET",
- "path": "/api/v1/rnsh/sessions/{session_id}/output"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnsh/sessions/{session_id}/resize"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnsh/sessions/{session_id}/start"
- },
- {
- "method": "POST",
- "path": "/api/v1/rnsh/sessions/{session_id}/stop"
- },
- {
- "method": "GET",
- "path": "/api/v1/rnstatus"
- },
- {
- "method": "GET",
- "path": "/api/v1/rrc/hubs"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/hubs"
- },
- {
- "method": "PUT",
- "path": "/api/v1/rrc/hubs/order"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/rrc/hubs/{hub_hash}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/rrc/hubs/{hub_hash}"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/hubs/{hub_hash}/command"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/hubs/{hub_hash}/connect"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/hubs/{hub_hash}/disconnect"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/hubs/{hub_hash}/rooms"
- },
- {
- "method": "PUT",
- "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/order"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/messages"
- },
- {
- "method": "GET",
- "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/messages"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/messages"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/read"
- },
- {
- "method": "GET",
- "path": "/api/v1/rrc/servers"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/servers"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/rrc/servers/{hub_id}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/rrc/servers/{hub_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/rrc/servers/{hub_id}/activity"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/servers/{hub_id}/announce"
- },
- {
- "method": "GET",
- "path": "/api/v1/rrc/servers/{hub_id}/members"
- },
- {
- "method": "GET",
- "path": "/api/v1/rrc/servers/{hub_id}/messages"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/servers/{hub_id}/moderate"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/servers/{hub_id}/rooms"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/rrc/servers/{hub_id}/rooms/{room}"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/servers/{hub_id}/start"
- },
- {
- "method": "POST",
- "path": "/api/v1/rrc/servers/{hub_id}/stop"
- },
- {
- "method": "POST",
- "path": "/api/v1/setup/storage-migration"
- },
- {
- "method": "GET",
- "path": "/api/v1/spam-keywords"
- },
- {
- "method": "POST",
- "path": "/api/v1/spam-keywords"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/spam-keywords/{keyword_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/status"
- },
- {
- "method": "GET",
- "path": "/api/v1/sticker-packs"
- },
- {
- "method": "POST",
- "path": "/api/v1/sticker-packs"
- },
- {
- "method": "POST",
- "path": "/api/v1/sticker-packs/install"
- },
- {
- "method": "POST",
- "path": "/api/v1/sticker-packs/reorder"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/sticker-packs/{pack_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/sticker-packs/{pack_id}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/sticker-packs/{pack_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/sticker-packs/{pack_id}/export"
- },
- {
- "method": "GET",
- "path": "/api/v1/stickers"
- },
- {
- "method": "POST",
- "path": "/api/v1/stickers"
- },
- {
- "method": "GET",
- "path": "/api/v1/stickers/export"
- },
- {
- "method": "POST",
- "path": "/api/v1/stickers/import"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/stickers/{sticker_id}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/stickers/{sticker_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/stickers/{sticker_id}/image"
- },
- {
- "method": "GET",
- "path": "/api/v1/system/network-interfaces"
- },
- {
- "method": "GET",
- "path": "/api/v1/telemetry/history/{destination_hash}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telemetry/latest/{destination_hash}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telemetry/peers"
- },
- {
- "method": "GET",
- "path": "/api/v1/telemetry/tracking"
- },
- {
- "method": "POST",
- "path": "/api/v1/telemetry/tracking/{destination_hash}/toggle"
- },
- {
- "method": "GET",
- "path": "/api/v1/telemetry/trusted-peers"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/answer"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/audio-profiles"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/call/{identity_hash}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/contacts"
- },
- {
- "method": "POST",
- "path": "/api/v1/telephone/contacts"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/contacts/check/{identity_hash}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/contacts/export"
- },
- {
- "method": "POST",
- "path": "/api/v1/telephone/contacts/import"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/telephone/contacts/{id}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/telephone/contacts/{id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/hangup"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/telephone/history"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/history"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/mute-receive"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/mute-transmit"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/recordings"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/telephone/recordings/{id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/recordings/{id}/audio/{side}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/ringtones"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/ringtones/status"
- },
- {
- "method": "POST",
- "path": "/api/v1/telephone/ringtones/upload"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/telephone/ringtones/{id}"
- },
- {
- "method": "PATCH",
- "path": "/api/v1/telephone/ringtones/{id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/ringtones/{id}/audio"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/send-to-voicemail"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/status"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/switch-audio-profile/{profile_id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/unmute-receive"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/unmute-transmit"
- },
- {
- "method": "POST",
- "path": "/api/v1/telephone/voicemail/generate-greeting"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/telephone/voicemail/greeting"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/voicemail/greeting/audio"
- },
- {
- "method": "POST",
- "path": "/api/v1/telephone/voicemail/greeting/record/start"
- },
- {
- "method": "POST",
- "path": "/api/v1/telephone/voicemail/greeting/record/stop"
- },
- {
- "method": "POST",
- "path": "/api/v1/telephone/voicemail/greeting/upload"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/voicemail/status"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/voicemails"
- },
- {
- "method": "DELETE",
- "path": "/api/v1/telephone/voicemails/{id}"
- },
- {
- "method": "GET",
- "path": "/api/v1/telephone/voicemails/{id}/audio"
- },
- {
- "method": "POST",
- "path": "/api/v1/telephone/voicemails/{id}/read"
- },
- {
- "method": "GET",
- "path": "/api/v1/tools/micron-parser-go-release"
- },
- {
- "method": "GET",
- "path": "/api/v1/tools/rnode/download_firmware"
- },
- {
- "method": "GET",
- "path": "/api/v1/tools/rnode/latest_release"
- },
- {
- "method": "POST",
- "path": "/api/v1/translator/install-languages"
- },
- {
- "method": "GET",
- "path": "/api/v1/translator/languages"
- },
- {
- "method": "POST",
- "path": "/api/v1/translator/translate"
- },
- {
- "method": "GET",
- "path": "/call.html"
- },
- {
- "method": "GET",
- "path": "/manifest.json"
- },
- {
- "method": "GET",
- "path": "/service-worker.js"
- },
- {
- "method": "GET",
- "path": "/ws"
- },
- {
- "method": "GET",
- "path": "/ws/telephone/audio"
- }
- ]
+ "routes": [
+ {
+ "method": "GET",
+ "path": "/"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/announce"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/announces"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/announces/query"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/app/changelog"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/app/changelog/seen"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/app/info"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/app/integrity/acknowledge"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/app/shutdown"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/app/tutorial/seen"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/auth/csrf"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/auth/login"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/auth/logout"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/auth/setup"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/auth/status"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/blocked-destinations"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/blocked-destinations"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/blocked-destinations/{destination_hash}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/bots/announce"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/bots/delete"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/bots/export"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/bots/restart"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/bots/start"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/bots/status"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/bots/stop"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/bots/subprocess-log"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/bots/update"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/community-interfaces"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/community-interfaces/refresh"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/comports"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/config"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/config"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/database/backup"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/database/backup/download"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/database/backups"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/database/backups/{filename}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/database/backups/{filename}/download"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/database/health"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/database/recover"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/database/restore"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/database/snapshot"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/database/snapshots"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/database/snapshots/{filename}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/database/snapshots/{filename}/download"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/database/vacuum"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/debug/access-attempts"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/debug/logs"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/destination/{destination_hash}/custom-display-name"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/destination/{destination_hash}/custom-display-name/update"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/destination/{destination_hash}/drop-path"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/destination/{destination_hash}/lxmf-stamp-info"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/destination/{destination_hash}/path"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/destination/{destination_hash}/request-path"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/destination/{destination_hash}/signal-metrics"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/diagnostics/memory"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/diagnostics/memory/gc"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/diagnostics/memory/gc/collect"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/diagnostics/memory/heap"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/diagnostics/memory/referrers"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/diagnostics/memory/reset"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/diagnostics/memory/snapshot"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/docs/export"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/docs/export/reticulum"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/docs/search"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/docs/status"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/docs/switch"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/docs/upload"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/docs/version/{version}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/favourites"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/favourites/add"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/favourites/import"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/favourites/{destination_hash}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/favourites/{destination_hash}/rename"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/gifs"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/gifs"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/gifs/export"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/gifs/import"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/gifs/{gif_id}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/gifs/{gif_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/gifs/{gif_id}/image"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/gifs/{gif_id}/use"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/identities"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/identities/create"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/identities/export-all"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/identities/switch"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/identities/{identity_hash}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/identity/backup/base32"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/identity/backup/download"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/identity/restore"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/interface-stats"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/licenses"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf-messages/attachment/{message_hash}/{attachment_type}"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/lxmf-messages/conversation/{destination_hash}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf-messages/conversation/{destination_hash}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf-messages/reactions"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf-messages/send"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/lxmf-messages/{hash}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf-messages/{hash}/cancel"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf-messages/{hash}/spam"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf-messages/{message_hash}/uri"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/conversation-pins"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/conversation-pins/toggle"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/conversations"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/conversations/bulk-delete"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/conversations/bulk-mark-as-read"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/conversations/move-to-folder"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/conversations/{destination_hash}/mark-as-read"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/folders"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/folders"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/folders/export"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/folders/import"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/lxmf/folders/{id}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/lxmf/folders/{id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/message-blocklist"
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/lxmf/message-blocklist"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/message-blocklist/export"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/message-blocklist/import"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/propagation-node/restart"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/propagation-node/status"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/lxmf/propagation-node/stop"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/propagation-node/stop-sync"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/propagation-node/sync"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/propagation-nodes"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/lxmf/sieve-filters"
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/lxmf/sieve-filters"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/maintenance/announces"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/maintenance/archives"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/maintenance/docs/reticulum"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/maintenance/favourites"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/maintenance/gifs"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/maintenance/lxmf-icons"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/maintenance/messages"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/maintenance/messages/export"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/maintenance/messages/import"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/maintenance/messages/import-file"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/maintenance/path-table"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/maintenance/stickers"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/map/drawings"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/map/drawings"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/map/drawings/{drawing_id}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/map/drawings/{drawing_id}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/map/export"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/map/export/{export_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/map/export/{export_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/map/export/{export_id}/download"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/map/mbtiles"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/map/mbtiles/active"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/map/mbtiles/{filename}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/map/offline"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/map/offline"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/map/tiles/{z}/{x}/{y}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/meshchatx-docs/content"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/meshchatx-docs/list"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/nomadnet/archives"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/nomadnet/archives"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/nomadnetwork/{destination_hash}/identify"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/notifications"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/notifications/mark-as-viewed"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/page-nodes"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/page-nodes"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/page-nodes/{node_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/page-nodes/{node_id}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/page-nodes/{node_id}/announce"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/page-nodes/{node_id}/files"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/page-nodes/{node_id}/files"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/page-nodes/{node_id}/files/{file_name}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/page-nodes/{node_id}/pages"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/page-nodes/{node_id}/pages"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/page-nodes/{node_id}/pages/{page_name}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/page-nodes/{node_id}/pages/{page_name}"
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/page-nodes/{node_id}/rename"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/page-nodes/{node_id}/start"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/page-nodes/{node_id}/stop"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/path-table"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/path-table"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/ping/{destination_hash}/lxmf.delivery"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/plugins"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/plugins/install"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/plugins/{plugin_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/plugins/{plugin_id}/asset/{asset_path:.*}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/plugins/{plugin_id}/disable"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/plugins/{plugin_id}/enable"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/plugins/{plugin_id}/invoke"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/repository-server/http/restart"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/repository-server/http/start"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/repository-server/http/stop"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/repository-server/list"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/repository-server/refresh-bundled"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/repository-server/status"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/repository-server/upload"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/repository-server/upload/{name}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/reticulum/blackhole"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/reticulum/config/raw"
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/reticulum/config/raw"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/config/reset"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/disable-transport"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/reticulum/discovered-interfaces"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/reticulum/discovery"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/reticulum/discovery"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/enable-transport"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/reticulum/interfaces"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/interfaces/add"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/interfaces/delete"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/interfaces/disable"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/interfaces/enable"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/interfaces/export"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/interfaces/import"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/interfaces/import-preview"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/reticulum/reload"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rncp/fetch"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rncp/listen"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rncp/send"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rncp/status"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rncp/stop"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rncp/transfer/{transfer_id}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnpath/drop"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnpath/drop-queues"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnpath/drop-via"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rnpath/rates"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnpath/request"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rnpath/table"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rnpath/trace/{destination_hash}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnprobe"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rnsh/sessions"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnsh/sessions"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/rnsh/sessions/{session_id}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnsh/sessions/{session_id}/clear"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnsh/sessions/{session_id}/input"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rnsh/sessions/{session_id}/output"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnsh/sessions/{session_id}/resize"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnsh/sessions/{session_id}/start"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rnsh/sessions/{session_id}/stop"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rnstatus"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rrc/hubs"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/hubs"
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/rrc/hubs/order"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/rrc/hubs/{hub_hash}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/rrc/hubs/{hub_hash}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/command"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/connect"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/disconnect"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/rooms"
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/order"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/messages"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/messages"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/messages"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/read"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rrc/servers"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/servers"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/rrc/servers/{hub_id}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/rrc/servers/{hub_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rrc/servers/{hub_id}/activity"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/servers/{hub_id}/announce"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rrc/servers/{hub_id}/members"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/rrc/servers/{hub_id}/messages"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/servers/{hub_id}/moderate"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/servers/{hub_id}/rooms"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/rrc/servers/{hub_id}/rooms/{room}"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/servers/{hub_id}/start"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/rrc/servers/{hub_id}/stop"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/server/security"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/server/security"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/setup/storage-migration"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/spam-keywords"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/spam-keywords"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/spam-keywords/{keyword_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/status"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/sticker-packs"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/sticker-packs"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/sticker-packs/install"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/sticker-packs/reorder"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/sticker-packs/{pack_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/sticker-packs/{pack_id}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/sticker-packs/{pack_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/sticker-packs/{pack_id}/export"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/stickers"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/stickers"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/stickers/export"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/stickers/import"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/stickers/{sticker_id}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/stickers/{sticker_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/stickers/{sticker_id}/image"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/system/network-interfaces"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telemetry/history/{destination_hash}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telemetry/latest/{destination_hash}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telemetry/peers"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telemetry/tracking"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telemetry/tracking/{destination_hash}/toggle"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telemetry/trusted-peers"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/answer"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/audio-profiles"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/call/{identity_hash}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/contacts"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/contacts"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/contacts/check/{identity_hash}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/contacts/export"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/contacts/import"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/telephone/contacts/{id}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/telephone/contacts/{id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/hangup"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/telephone/history"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/history"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/mute-receive"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/mute-transmit"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/recordings"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/telephone/recordings/{id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/recordings/{id}/audio/{side}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/ringtones"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/ringtones/status"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/ringtones/upload"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/telephone/ringtones/{id}"
+ },
+ {
+ "method": "PATCH",
+ "path": "/api/v1/telephone/ringtones/{id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/ringtones/{id}/audio"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/send-to-voicemail"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/status"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/switch-audio-profile/{profile_id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/unmute-receive"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/unmute-transmit"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/voicemail/generate-greeting"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/telephone/voicemail/greeting"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/voicemail/greeting/audio"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/voicemail/greeting/record/start"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/voicemail/greeting/record/stop"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/voicemail/greeting/upload"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/voicemail/status"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/voicemails"
+ },
+ {
+ "method": "DELETE",
+ "path": "/api/v1/telephone/voicemails/{id}"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/telephone/voicemails/{id}/audio"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/telephone/voicemails/{id}/read"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/tools/micron-parser-go-release"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/tools/rnode/download_firmware"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/tools/rnode/latest_release"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/translator/install-languages"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/translator/languages"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/translator/translate"
+ },
+ {
+ "method": "GET",
+ "path": "/call.html"
+ },
+ {
+ "method": "GET",
+ "path": "/manifest.json"
+ },
+ {
+ "method": "GET",
+ "path": "/service-worker.js"
+ },
+ {
+ "method": "GET",
+ "path": "/ws"
+ },
+ {
+ "method": "GET",
+ "path": "/ws/telephone/audio"
+ }
+ ]
}
diff --git a/tests/backend/test_plugin_manager.py b/tests/backend/test_plugin_manager.py
new file mode 100644
index 00000000..a0220740
--- /dev/null
+++ b/tests/backend/test_plugin_manager.py
@@ -0,0 +1,57 @@
+# SPDX-License-Identifier: 0BSD
+
+import json
+import os
+import tempfile
+
+import pytest
+
+
+def _make_manager(tmp_path, app=None):
+ from meshchatx.src.backend.plugin_manager import PluginManager
+
+ return PluginManager(str(tmp_path), app=app)
+
+
+class TestPluginManagerInstall:
+ def test_install_bundled_example(self, tmp_path):
+ manager = _make_manager(tmp_path)
+ manager.install_bundled_examples()
+ plugins = manager.list_plugins()
+ ids = [plugin["id"] for plugin in plugins]
+ assert "com.meshchatx.transport-node-monitor" in ids
+
+ def test_enable_disable_plugin(self, tmp_path):
+ manager = _make_manager(tmp_path)
+ manager.install_bundled_examples()
+ plugin_id = "com.meshchatx.transport-node-monitor"
+ enabled = manager.enable(plugin_id)
+ assert enabled["enabled"] is True
+ disabled = manager.disable(plugin_id)
+ assert disabled["enabled"] is False
+
+ def test_invoke_storage_roundtrip(self, tmp_path):
+ manager = _make_manager(tmp_path)
+ manager.install_bundled_examples()
+ plugin_id = "com.meshchatx.transport-node-monitor"
+ manager.enable(plugin_id)
+ manager.invoke(plugin_id, "setWatchedNodes", {"nodes": ["abc123"]})
+ state = manager.invoke(plugin_id, "getState")
+ assert state["watched_nodes"] == ["abc123"]
+
+ def test_permission_denied_for_manager_capability(self, tmp_path):
+ manager = _make_manager(tmp_path)
+ manager.install_bundled_examples()
+ plugin_id = "com.meshchatx.transport-node-monitor"
+ manager.enable(plugin_id)
+ with pytest.raises(PermissionError):
+ manager.call_manager(plugin_id, "unknown.capability", {})
+
+ def test_manifest_validation_rejects_invalid_id(self, tmp_path):
+ manager = _make_manager(tmp_path)
+ plugin_dir = os.path.join(tmp_path, "bad-plugin")
+ os.makedirs(plugin_dir, exist_ok=True)
+ with open(os.path.join(plugin_dir, "plugin.json"), "w", encoding="utf-8") as handle:
+ json.dump({"id": "bad id", "version": "1.0.0", "apiVersion": 1}, handle)
+ with pytest.raises(ValueError):
+ manager.install_from_directory(plugin_dir)
diff --git a/tests/frontend/ToolsPage.test.js b/tests/frontend/ToolsPage.test.js
index 8235b8f6..99d6d1cd 100644
--- a/tests/frontend/ToolsPage.test.js
+++ b/tests/frontend/ToolsPage.test.js
@@ -2,8 +2,10 @@ import { mount } from "@vue/test-utils";
import { describe, it, expect, vi } from "vitest";
import ToolsPage from "@/components/tools/ToolsPage.vue";
import { createRouter, createWebHistory } from "vue-router";
+import { registerCoreContributions } from "@/js/registries/registerCoreContributions.js";
describe("ToolsPage.vue", () => {
+ registerCoreContributions();
const router = createRouter({
history: createWebHistory(),
routes: [
diff --git a/tests/frontend/pluginManifest.test.js b/tests/frontend/pluginManifest.test.js
new file mode 100644
index 00000000..dc800575
--- /dev/null
+++ b/tests/frontend/pluginManifest.test.js
@@ -0,0 +1,38 @@
+// SPDX-License-Identifier: 0BSD
+
+import { describe, expect, it } from "vitest";
+import { validatePluginManifest, manifestPermissionSummary } from "../../meshchatx/src/frontend/js/plugins/pluginManifest.js";
+
+describe("pluginManifest", () => {
+ it("validates a minimal manifest", () => {
+ const manifest = validatePluginManifest({
+ id: "com.example.demo",
+ version: "1.0.0",
+ apiVersion: 1,
+ frontend: { entry: "frontend/main.js", type: "js" },
+ });
+ expect(manifest.id).toBe("com.example.demo");
+ });
+
+ it("rejects unsupported api versions", () => {
+ expect(() =>
+ validatePluginManifest({
+ id: "com.example.demo",
+ version: "1.0.0",
+ apiVersion: 99,
+ })
+ ).toThrow(/apiVersion/);
+ });
+
+ it("summarizes permissions", () => {
+ const lines = manifestPermissionSummary({
+ permissions: {
+ hooks: ["announce.received"],
+ managers: ["destinationPath.read"],
+ storage: "isolated",
+ },
+ });
+ expect(lines.join(" ")).toContain("announce.received");
+ expect(lines.join(" ")).toContain("destinationPath.read");
+ });
+});
diff --git a/tests/frontend/registries.test.js b/tests/frontend/registries.test.js
new file mode 100644
index 00000000..4f2b663d
--- /dev/null
+++ b/tests/frontend/registries.test.js
@@ -0,0 +1,107 @@
+// SPDX-License-Identifier: 0BSD
+
+import { describe, expect, it, beforeEach } from "vitest";
+import { createRegistry } from "../../meshchatx/src/frontend/js/registries/registryCore.js";
+import { navRegistry, registerNavItem, unregisterNavItem, listNavItems } from "../../meshchatx/src/frontend/js/registries/navRegistry.js";
+import { toolsRegistry, registerTool, listTools } from "../../meshchatx/src/frontend/js/registries/toolsRegistry.js";
+import { commandRegistry, registerCommand, listCommands } from "../../meshchatx/src/frontend/js/registries/commandRegistry.js";
+import {
+ settingsSectionRegistry,
+ registerSettingsSection,
+ getAllSettingsSectionKeywords,
+} from "../../meshchatx/src/frontend/js/registries/settingsSectionRegistry.js";
+import { registerCoreContributions, resetCoreContributionsForTests } from "../../meshchatx/src/frontend/js/registries/registerCoreContributions.js";
+import { CORE_NAV_ENTRIES } from "../../meshchatx/src/frontend/js/registries/coreNavEntries.js";
+import { CORE_TOOLS_ENTRIES } from "../../meshchatx/src/frontend/js/registries/coreToolsEntries.js";
+
+describe("registryCore", () => {
+ it("registers and lists entries", () => {
+ const registry = createRegistry("test");
+ registry.register({ id: "a", value: 1 });
+ registry.register({ id: "b", value: 2 });
+ expect(registry.list()).toHaveLength(2);
+ expect(registry.get("a")?.value).toBe(1);
+ });
+
+ it("rejects duplicate ids", () => {
+ const registry = createRegistry("test");
+ registry.register({ id: "dup" });
+ expect(() => registry.register({ id: "dup" })).toThrow(/duplicate/);
+ });
+
+ it("unregisters entries", () => {
+ const registry = createRegistry("test");
+ registry.register({ id: "x" });
+ registry.unregister("x");
+ expect(registry.list()).toHaveLength(0);
+ });
+});
+
+describe("contribution registries", () => {
+ beforeEach(() => {
+ resetCoreContributionsForTests();
+ navRegistry.clear();
+ toolsRegistry.clear();
+ commandRegistry.clear();
+ settingsSectionRegistry.clear();
+ });
+
+ it("registers nav items", () => {
+ registerNavItem({
+ id: "test",
+ route: { name: "about" },
+ icon: "information",
+ labelKey: "app.about",
+ });
+ expect(listNavItems()).toHaveLength(1);
+ unregisterNavItem("test");
+ expect(listNavItems()).toHaveLength(0);
+ });
+
+ it("registers tools by name", () => {
+ registerTool({
+ name: "custom-tool",
+ route: { name: "ping" },
+ icon: "radar",
+ iconBg: "tool-card__icon",
+ titleKey: "tools.ping.title",
+ descriptionKey: "tools.ping.description",
+ });
+ expect(listTools()).toHaveLength(1);
+ expect(listTools()[0].name).toBe("custom-tool");
+ });
+
+ it("registers commands", () => {
+ registerCommand({
+ id: "cmd-test",
+ title: "nav_ping",
+ description: "nav_ping_desc",
+ icon: "radar",
+ type: "navigation",
+ route: { name: "ping" },
+ });
+ expect(listCommands()).toHaveLength(1);
+ });
+
+ it("registers settings section keywords", () => {
+ registerSettingsSection({ id: "plugins", keywords: ["Plugins", "extensions"] });
+ expect(getAllSettingsSectionKeywords().plugins).toContain("Plugins");
+ });
+});
+
+describe("registerCoreContributions", () => {
+ beforeEach(() => {
+ resetCoreContributionsForTests();
+ navRegistry.clear();
+ toolsRegistry.clear();
+ commandRegistry.clear();
+ settingsSectionRegistry.clear();
+ });
+
+ it("loads all core entries once", () => {
+ registerCoreContributions();
+ registerCoreContributions();
+ expect(listNavItems()).toHaveLength(CORE_NAV_ENTRIES.length);
+ expect(listTools()).toHaveLength(CORE_TOOLS_ENTRIES.length);
+ });
+});
diff --git a/tests/frontend/settingsTabs.test.js b/tests/frontend/settingsTabs.test.js
index 37730168..94617cba 100644
--- a/tests/frontend/settingsTabs.test.js
+++ b/tests/frontend/settingsTabs.test.js
@@ -18,6 +18,7 @@ const KNOWN_SECTIONS_FROM_SETTINGS_PAGE = [
"stickers",
"gifs",
"maintenance",
+ "plugins",
"telephony",
"desktop",
"android",
diff --git a/tests/frontend/wsEventRegistry.test.js b/tests/frontend/wsEventRegistry.test.js
new file mode 100644
index 00000000..6d9b8ffb
--- /dev/null
+++ b/tests/frontend/wsEventRegistry.test.js
@@ -0,0 +1,26 @@
+// SPDX-License-Identifier: 0BSD
+
+import { describe, expect, it, vi } from "vitest";
+import { dispatchWsEvent, onWsEvent, offWsEvent } from "../../meshchatx/src/frontend/js/registries/wsEventRegistry.js";
+
+describe("wsEventRegistry", () => {
+ it("dispatches to registered handlers by type", async () => {
+ const handler = vi.fn();
+ onWsEvent("config", handler);
+ await dispatchWsEvent("config", { type: "config", config: { theme: "dark" } });
+ expect(handler).toHaveBeenCalledWith({ type: "config", config: { theme: "dark" } });
+ offWsEvent("config", handler);
+ });
+
+ it("supports multiple handlers for one type", async () => {
+ const first = vi.fn();
+ const second = vi.fn();
+ onWsEvent("announce", first);
+ onWsEvent("announce", second);
+ await dispatchWsEvent("announce", { type: "announce" });
+ expect(first).toHaveBeenCalled();
+ expect(second).toHaveBeenCalled();
+ offWsEvent("announce", first);
+ offWsEvent("announce", second);
+ });
+});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────